Compare commits

...

1 Commits

Author SHA1 Message Date
Dhruwang
c092f005b6 validation poc 2026-01-06 14:51:30 +05:30
27 changed files with 1127 additions and 82 deletions

View File

@@ -1588,6 +1588,8 @@
"upload_at_least_2_images": "Upload at least 2 images",
"upper_label": "Upper Label",
"url_filters": "URL Filters",
"validation_rules": "Validation rules",
"validation_rules_description": "Only accept responses that meet the following criteria",
"url_not_supported": "URL not supported",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} is used in logic of question {questionIndex}. Please remove it from logic first.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" is being used in \"{quotaName}\" quota",

View File

@@ -11,12 +11,14 @@ import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum, TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
import { TValidationRule } from "@formbricks/types/surveys/validation-rules";
import { TShuffleOption, TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { BulkEditOptionsModal } from "@/modules/survey/editor/components/bulk-edit-options-modal";
import { ElementOptionChoice } from "@/modules/survey/editor/components/element-option-choice";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
@@ -398,6 +400,19 @@ export const MultipleChoiceElementForm = ({
surveyLanguageCodes={surveyLanguageCodes}
locale={locale}
/>
{/* Validation Rules Editor - only for MultipleChoiceMulti */}
{element.type === TSurveyElementTypeEnum.MultipleChoiceMulti && (
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.MultipleChoiceMulti}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRule[]) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
)}
</form>
);
};

View File

@@ -4,11 +4,17 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { HashIcon, LinkIcon, MailIcon, MessageSquareTextIcon, PhoneIcon, PlusIcon } from "lucide-react";
import { JSX, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurveyOpenTextElement, TSurveyOpenTextElementInputType } from "@formbricks/types/surveys/elements";
import {
TSurveyElementTypeEnum,
TSurveyOpenTextElement,
TSurveyOpenTextElementInputType,
} from "@formbricks/types/surveys/elements";
import { TValidationRule } from "@formbricks/types/surveys/validation-rules";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
@@ -245,6 +251,17 @@ export const OpenElementForm = ({
customContainerClass="p-0"
/>
</div>
{/* Validation Rules Editor */}
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.OpenText}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRule[]) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
/>
</div>
</form>
);

View File

@@ -0,0 +1,341 @@
"use client";
import { createId } from "@paralleldrive/cuid2";
import { PlusIcon, TrashIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import {
APPLICABLE_RULES,
TValidationRule,
TValidationRuleType,
} from "@formbricks/types/surveys/validation-rules";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
interface ValidationRulesEditorProps {
elementType: TSurveyElementTypeEnum;
validationRules: TValidationRule[];
onUpdateRules: (rules: TValidationRule[]) => void;
}
// Rule type definitions with labels and whether they need a value input
const RULE_TYPE_CONFIG: Record<
TValidationRuleType,
{
label: string;
needsValue: boolean;
valueType?: "number" | "text";
valuePlaceholder?: string;
unitOptions?: { value: string; label: string }[];
}
> = {
required: {
label: "Is not empty",
needsValue: false,
},
minLength: {
label: "Is longer than",
needsValue: true,
valueType: "number",
valuePlaceholder: "100",
unitOptions: [
{ value: "characters", label: "characters" },
],
},
maxLength: {
label: "Is shorter than",
needsValue: true,
valueType: "number",
valuePlaceholder: "500",
unitOptions: [
{ value: "characters", label: "characters" },
],
},
pattern: {
label: "Matches pattern",
needsValue: true,
valueType: "text",
valuePlaceholder: "^[A-Z].*",
},
email: {
label: "Is valid email",
needsValue: false,
},
url: {
label: "Is valid URL",
needsValue: false,
},
phone: {
label: "Is valid phone",
needsValue: false,
},
minValue: {
label: "Is greater than",
needsValue: true,
valueType: "number",
valuePlaceholder: "0",
},
maxValue: {
label: "Is less than",
needsValue: true,
valueType: "number",
valuePlaceholder: "100",
},
minSelections: {
label: "At least",
needsValue: true,
valueType: "number",
valuePlaceholder: "1",
unitOptions: [{ value: "options", label: "options selected" }],
},
maxSelections: {
label: "At most",
needsValue: true,
valueType: "number",
valuePlaceholder: "3",
unitOptions: [{ value: "options", label: "options selected" }],
},
};
// Get available rule types for an element type
const getAvailableRuleTypes = (
elementType: TSurveyElementTypeEnum,
existingRules: TValidationRule[]
): TValidationRuleType[] => {
const elementTypeKey = elementType.toString();
const applicable = APPLICABLE_RULES[elementTypeKey] ?? [];
// Filter out rules that are already added (for non-repeatable rules)
const existingTypes = new Set(existingRules.map((r) => r.params.type));
return applicable.filter((ruleType) => {
// Allow only one of each rule type
return !existingTypes.has(ruleType);
});
};
// Get the value from rule params based on rule type
const getRuleValue = (rule: TValidationRule): number | string | undefined => {
const params = rule.params as Record<string, unknown>;
if ("min" in params) return params.min as number;
if ("max" in params) return params.max as number;
if ("pattern" in params) return params.pattern as string;
return undefined;
};
// Create params object from rule type and value
const createRuleParams = (
ruleType: TValidationRuleType,
value?: number | string
): TValidationRule["params"] => {
switch (ruleType) {
case "required":
return { type: "required" };
case "minLength":
return { type: "minLength", min: Number(value) || 0 };
case "maxLength":
return { type: "maxLength", max: Number(value) || 100 };
case "pattern":
return { type: "pattern", pattern: String(value) || "" };
case "email":
return { type: "email" };
case "url":
return { type: "url" };
case "phone":
return { type: "phone" };
case "minValue":
return { type: "minValue", min: Number(value) || 0 };
case "maxValue":
return { type: "maxValue", max: Number(value) || 100 };
case "minSelections":
return { type: "minSelections", min: Number(value) || 1 };
case "maxSelections":
return { type: "maxSelections", max: Number(value) || 3 };
default:
return { type: "required" };
}
};
export const ValidationRulesEditor = ({
elementType,
validationRules,
onUpdateRules,
}: ValidationRulesEditorProps) => {
const { t } = useTranslation();
const isEnabled = validationRules.length > 0;
const handleEnable = () => {
const availableRules = getAvailableRuleTypes(elementType, []);
if (availableRules.length > 0) {
const defaultRuleType = availableRules[0];
const newRule: TValidationRule = {
id: createId(),
params: createRuleParams(defaultRuleType),
enabled: true,
};
onUpdateRules([newRule]);
}
};
const handleDisable = () => {
onUpdateRules([]);
};
const handleToggle = (checked: boolean) => {
if (checked) {
handleEnable();
} else {
handleDisable();
}
};
const handleAddRule = () => {
const availableRules = getAvailableRuleTypes(elementType, validationRules);
if (availableRules.length === 0) return;
const newRuleType = availableRules[0];
const newRule: TValidationRule = {
id: createId(),
params: createRuleParams(newRuleType),
enabled: true,
};
onUpdateRules([...validationRules, newRule]);
};
const handleDeleteRule = (ruleId: string) => {
const updated = validationRules.filter((r) => r.id !== ruleId);
onUpdateRules(updated);
};
const handleRuleTypeChange = (ruleId: string, newType: TValidationRuleType) => {
const updated = validationRules.map((rule) => {
if (rule.id !== ruleId) return rule;
return {
...rule,
params: createRuleParams(newType),
};
});
onUpdateRules(updated);
};
const handleRuleValueChange = (ruleId: string, value: string) => {
const updated = validationRules.map((rule) => {
if (rule.id !== ruleId) return rule;
const ruleType = rule.params.type;
const config = RULE_TYPE_CONFIG[ruleType];
const parsedValue = config.valueType === "number" ? Number(value) || 0 : value;
return {
...rule,
params: createRuleParams(ruleType, parsedValue),
};
});
onUpdateRules(updated);
};
const availableRulesForAdd = getAvailableRuleTypes(elementType, validationRules);
const canAddMore = availableRulesForAdd.length > 0;
return (
<AdvancedOptionToggle
isChecked={isEnabled}
onToggle={handleToggle}
htmlId="validation-rules-toggle"
title={t("environments.surveys.edit.validation_rules")}
description={t("environments.surveys.edit.validation_rules_description")}
customContainerClass="p-0 mt-4"
childrenContainerClass="flex-col p-3 gap-2">
{validationRules.map((rule, index) => {
const ruleType = rule.params.type;
const config = RULE_TYPE_CONFIG[ruleType];
const currentValue = getRuleValue(rule);
// Get available types for this rule (current type + unused types, no duplicates)
const otherAvailableTypes = getAvailableRuleTypes(
elementType,
validationRules.filter((r) => r.id !== rule.id)
).filter((t) => t !== ruleType);
const availableTypesForSelect = [ruleType, ...otherAvailableTypes];
return (
<div key={rule.id} className="flex w-full items-center gap-2">
{/* Rule Type Selector */}
<Select value={ruleType} onValueChange={(value) => handleRuleTypeChange(rule.id, value as TValidationRuleType)}>
<SelectTrigger className={config.needsValue ? "w-[160px]" : "flex-1"}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableTypesForSelect.map((type) => (
<SelectItem key={type} value={type}>
{RULE_TYPE_CONFIG[type].label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Value Input (if needed) */}
{config.needsValue && (
<>
<Input
type={config.valueType === "number" ? "number" : "text"}
value={currentValue ?? ""}
onChange={(e) => handleRuleValueChange(rule.id, e.target.value)}
placeholder={config.valuePlaceholder}
className="w-[80px] bg-white"
min={config.valueType === "number" ? 0 : undefined}
/>
{/* Unit selector (if applicable) */}
{config.unitOptions && config.unitOptions.length > 0 && (
<Select value={config.unitOptions[0].value} disabled>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{config.unitOptions.map((unit) => (
<SelectItem key={unit.value} value={unit.value}>
{unit.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</>
)}
{/* Delete button */}
<Button
variant="secondary"
size="icon"
type="button"
onClick={() => handleDeleteRule(rule.id)}
className="shrink-0">
<TrashIcon className="h-4 w-4" />
</Button>
{/* Add button (only on last row and if can add more) */}
{index === validationRules.length - 1 && canAddMore && (
<Button
variant="secondary"
size="icon"
type="button"
onClick={handleAddRule}
className="shrink-0">
<PlusIcon className="h-4 w-4" />
</Button>
)}
</div>
);
})}
</AdvancedOptionToggle>
);
};

View File

@@ -44,8 +44,15 @@
},
"invalid_device_error": {
"message": "Please disable spam protection in the survey settings to continue using this device.",
"title": "This device doesnt support spam protection."
"title": "This device doesn't support spam protection."
},
"invalid_format": "Please enter a valid format",
"max_length": "Please enter no more than {max} characters",
"max_selections": "Please select no more than {max} options",
"max_value": "Please enter a value no greater than {max}",
"min_length": "Please enter at least {min} characters",
"min_selections": "Please select at least {min} options",
"min_value": "Please enter a value of at least {min}",
"please_book_an_appointment": "Please book an appointment",
"please_enter_a_valid_email_address": "Please enter a valid email address",
"please_enter_a_valid_phone_number": "Please enter a valid phone number",

View File

@@ -17,6 +17,7 @@ interface MultipleChoiceMultiElementProps {
autoFocusEnabled: boolean;
currentElementId: string;
dir?: "ltr" | "rtl" | "auto";
errorMessage?: string; // Validation error from centralized validation
}
export function MultipleChoiceMultiElement({
@@ -28,10 +29,10 @@ export function MultipleChoiceMultiElement({
setTtc,
currentElementId,
dir = "auto",
errorMessage,
}: Readonly<MultipleChoiceMultiElementProps>) {
const [startTime, setStartTime] = useState(performance.now());
const [otherValue, setOtherValue] = useState("");
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
const isCurrent = element.id === currentElementId;
const { t } = useTranslation();
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
@@ -173,22 +174,9 @@ export function MultipleChoiceMultiElement({
onChange({ [element.id]: nextValue });
};
const validateRequired = (): boolean => {
if (element.required && (!Array.isArray(value) || value.length === 0)) {
setErrorMessage(t("errors.please_select_an_option"));
return false;
}
if (element.required && isOtherSelected && !otherValue.trim()) {
setErrorMessage(t("errors.please_fill_out_this_field"));
return false;
}
return true;
};
const handleSubmit = (e: Event) => {
e.preventDefault();
setErrorMessage(undefined);
if (!validateRequired()) return;
// Update TTC when form is submitted (for TTC collection)
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
setTtc(updatedTtcObj);
};
@@ -228,7 +216,6 @@ export function MultipleChoiceMultiElement({
// Handle selection changes - store labels directly instead of IDs
const handleMultiSelectChange = (selectedIds: string[]) => {
setErrorMessage(undefined);
const nextLabels: string[] = [];
const isOtherNowSelected = Boolean(otherOption) && selectedIds.includes(otherOption!.id);

View File

@@ -1,7 +1,5 @@
import { useState } from "preact/hooks";
import { useTranslation } from "react-i18next";
import { OpenText } from "@formbricks/survey-ui";
import { ZEmail, ZUrl } from "@formbricks/types/common";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyOpenTextElement } from "@formbricks/types/surveys/elements";
import { getLocalizedValue } from "@/lib/i18n";
@@ -18,6 +16,7 @@ interface OpenTextElementProps {
autoFocusEnabled: boolean;
currentElementId: string;
dir?: "ltr" | "rtl" | "auto";
errorMessage?: string; // Validation error from centralized validation
}
export function OpenTextElement({
@@ -29,76 +28,19 @@ export function OpenTextElement({
setTtc,
currentElementId,
dir = "auto",
errorMessage,
}: Readonly<OpenTextElementProps>) {
const [startTime, setStartTime] = useState(performance.now());
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
const isCurrent = element.id === currentElementId;
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
const { t } = useTranslation();
const handleChange = (inputValue: string) => {
// Clear error when user starts typing
setErrorMessage(undefined);
onChange({ [element.id]: inputValue });
};
const validateRequired = (): boolean => {
if (element.required && (!value || value.trim() === "")) {
setErrorMessage(t("errors.please_fill_out_this_field"));
return false;
}
return true;
};
const validateEmail = (): boolean => {
if (!ZEmail.safeParse(value).success) {
setErrorMessage(t("errors.please_enter_a_valid_email_address"));
return false;
}
return true;
};
const validateUrl = (): boolean => {
if (!ZUrl.safeParse(value).success) {
setErrorMessage(t("errors.please_enter_a_valid_url"));
return false;
}
return true;
};
const validatePhone = (): boolean => {
// Match the same pattern: must start with digit or +, end with digit
// Allows digits, +, -, and spaces in between
const phoneRegex = /^[0-9+][0-9+\- ]*[0-9]$/;
if (!phoneRegex.test(value)) {
setErrorMessage(t("errors.please_enter_a_valid_phone_number"));
return false;
}
return true;
};
const validateInput = (): boolean => {
if (!value || value.trim() === "") return true;
if (element.inputType === "email") {
return validateEmail();
}
if (element.inputType === "url") {
return validateUrl();
}
if (element.inputType === "phone") {
return validatePhone();
}
return true;
};
const handleOnSubmit = (e: Event) => {
e.preventDefault();
setErrorMessage(undefined);
if (!validateRequired()) return;
if (!validateInput()) return;
// Update TTC when form is submitted (for TTC collection)
const updatedTtc = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
setTtc(updatedTtc);
};

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "preact/hooks";
import { useTranslation } from "react-i18next";
import { type TJsFileUploadParams } from "@formbricks/types/js";
import { type TResponseData, TResponseDataValue, type TResponseTtc } from "@formbricks/types/responses";
import { type TUploadFileConfig } from "@formbricks/types/storage";
@@ -9,12 +10,14 @@ import {
TSurveyMatrixElement,
TSurveyRankingElement,
} from "@formbricks/types/surveys/elements";
import { TValidationErrorMap } from "@formbricks/types/surveys/validation-rules";
import { BackButton } from "@/components/buttons/back-button";
import { SubmitButton } from "@/components/buttons/submit-button";
import { ElementConditional } from "@/components/general/element-conditional";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { cn } from "@/lib/utils";
import { validateBlockResponses, getFirstErrorMessage } from "@/lib/validation";
interface BlockConditionalProps {
block: TSurveyBlock;
@@ -59,9 +62,14 @@ export function BlockConditional({
dir,
fullSizeCards,
}: BlockConditionalProps) {
const { t } = useTranslation();
// Track the current element being filled (for TTC tracking)
const [currentElementId, setCurrentElementId] = useState(block.elements[0]?.id);
// State to store validation errors from centralized validation
const [elementErrors, setElementErrors] = useState<TValidationErrorMap>({});
// Refs to store form elements for each element so we can trigger their validation
const elementFormRefs = useRef<Map<string, HTMLFormElement>>(new Map());
@@ -74,6 +82,14 @@ export function BlockConditional({
if (elementId !== currentElementId) {
setCurrentElementId(elementId);
}
// Clear error for this element when user makes a change
if (elementErrors[elementId]) {
setElementErrors((prev) => {
const updated = { ...prev };
delete updated[elementId];
return updated;
});
}
// Merge with existing block data to preserve other element values
onChange({ ...value, ...responseData });
};
@@ -263,15 +279,34 @@ export function BlockConditional({
e.preventDefault();
}
// Validate all forms and check for custom validation rules
const firstInvalidForm = findFirstInvalidForm();
// Run centralized validation for elements that support it (OpenText, MultiSelect)
const errorMap = validateBlockResponses(block.elements, value, languageCode, t);
// If any form is invalid, scroll to it and stop
// Check if there are any validation errors from centralized validation
const hasValidationErrors = Object.keys(errorMap).length > 0;
if (hasValidationErrors) {
setElementErrors(errorMap);
// Find the first element with an error and scroll to it
const firstErrorElementId = Object.keys(errorMap)[0];
const form = elementFormRefs.current.get(firstErrorElementId);
if (form) {
form.scrollIntoView({ behavior: "smooth", block: "center" });
}
return;
}
// Also run legacy validation for elements not yet migrated to centralized validation
const firstInvalidForm = findFirstInvalidForm();
if (firstInvalidForm) {
firstInvalidForm.scrollIntoView({ behavior: "smooth", block: "center" });
return;
}
// Clear any previous errors
setElementErrors({});
// Collect TTC and responses, then submit
const blockTtc = collectTtcValues();
const blockResponses = collectBlockResponses();
@@ -310,6 +345,7 @@ export function BlockConditional({
}
}}
onTtcCollect={handleTtcCollect}
errorMessage={getFirstErrorMessage(elementErrors, element.id)}
/>
</div>
);

View File

@@ -39,6 +39,7 @@ interface ElementConditionalProps {
dir?: "ltr" | "rtl" | "auto";
formRef?: (ref: HTMLFormElement | null) => void; // Callback to expose the form element
onTtcCollect?: (elementId: string, ttc: number) => void; // Callback to collect TTC synchronously
errorMessage?: string; // Validation error message from centralized validation
}
export function ElementConditional({
@@ -56,6 +57,7 @@ export function ElementConditional({
dir,
formRef,
onTtcCollect,
errorMessage,
}: ElementConditionalProps) {
// Ref to the container div, used to find and expose the form element inside
const containerRef = useRef<HTMLDivElement>(null);
@@ -124,6 +126,7 @@ export function ElementConditional({
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
dir={dir}
errorMessage={errorMessage}
/>
);
case TSurveyElementTypeEnum.MultipleChoiceSingle:
@@ -154,6 +157,7 @@ export function ElementConditional({
autoFocusEnabled={autoFocusEnabled}
currentElementId={currentElementId}
dir={dir}
errorMessage={errorMessage}
/>
);
case TSurveyElementTypeEnum.NPS:

View File

@@ -0,0 +1,151 @@
import type { TFunction } from "i18next";
import type { TResponseData, TResponseDataValue } from "@formbricks/types/responses";
import type {
TSurveyElement,
TSurveyElementTypeEnum,
TSurveyOpenTextElement,
} from "@formbricks/types/surveys/elements";
import type {
TValidationError,
TValidationErrorMap,
TValidationResult,
TValidationRule,
TValidationRuleType,
} from "@formbricks/types/surveys/validation-rules";
import { validators } from "./validators";
/**
* Check if an element is an OpenText element with inputType
*/
const isOpenTextElement = (element: TSurveyElement): element is TSurveyOpenTextElement => {
return element.type === ("openText" as TSurveyElementTypeEnum);
};
/**
* Single entrypoint for validating an element's response value.
* Called by block-conditional.tsx during form submission.
*
* @param element - The survey element being validated
* @param value - The response value for this element
* @param languageCode - Current language code for error messages
* @param t - i18next translation function
* @returns Validation result with valid flag and array of errors
*/
export const validateElementResponse = (
element: TSurveyElement,
value: TResponseDataValue,
languageCode: string,
t: TFunction
): TValidationResult => {
const errors: TValidationError[] = [];
const rules: TValidationRule[] = [...(element.validationRules ?? [])];
// Handle legacy `required` field for backwards compatibility
// If element.required is true and no explicit "required" rule exists, add one
if (element.required && !rules.some((r) => r.params.type === "required")) {
const legacyRequiredRule: TValidationRule = {
id: "__legacy_required__",
params: { type: "required" },
enabled: true,
};
rules.unshift(legacyRequiredRule);
}
// Handle legacy `inputType` field for OpenText elements
// If inputType is email/url/phone and no explicit rule exists, add one
if (isOpenTextElement(element) && element.inputType) {
const inputTypeToRuleType: Record<string, TValidationRuleType> = {
email: "email",
url: "url",
phone: "phone",
};
const ruleType = inputTypeToRuleType[element.inputType];
if (ruleType && !rules.some((r) => r.params.type === ruleType)) {
const legacyInputTypeRule: TValidationRule = {
id: `__legacy_${element.inputType}__`,
params: { type: ruleType } as TValidationRule["params"],
enabled: true,
};
rules.push(legacyInputTypeRule);
}
}
for (const rule of rules) {
// Skip disabled rules
if (rule.enabled === false) {
continue;
}
const ruleType = rule.params.type as TValidationRuleType;
const validator = validators[ruleType];
if (!validator) {
console.warn(`Unknown validation rule type: ${ruleType}`);
continue;
}
const checkResult = validator.check(value, rule.params, element);
if (!checkResult.valid) {
// Use custom error message if provided, otherwise use default
const message =
rule.customErrorMessage?.[languageCode] ??
rule.customErrorMessage?.default ??
validator.getDefaultMessage(rule.params, t);
errors.push({
ruleId: rule.id,
ruleType,
message,
});
}
}
return { valid: errors.length === 0, errors };
};
/**
* Validate all elements in a block, returning an error map.
*
* @param elements - Array of elements to validate
* @param responses - Response data keyed by element ID
* @param languageCode - Current language code for error messages
* @param t - i18next translation function
* @returns Map of element IDs to their validation errors
*/
export const validateBlockResponses = (
elements: TSurveyElement[],
responses: TResponseData,
languageCode: string,
t: TFunction
): TValidationErrorMap => {
const errorMap: TValidationErrorMap = {};
for (const element of elements) {
const result = validateElementResponse(element, responses[element.id], languageCode, t);
if (!result.valid) {
errorMap[element.id] = result.errors;
}
}
return errorMap;
};
/**
* Get the first error message for an element from the error map.
* Useful for UI components that only display one error at a time.
*
* @param errorMap - The validation error map
* @param elementId - The element ID to get error for
* @returns The first error message or undefined
*/
export const getFirstErrorMessage = (
errorMap: TValidationErrorMap,
elementId: string
): string | undefined => {
const errors = errorMap[elementId];
return errors?.[0]?.message;
};

View File

@@ -0,0 +1,5 @@
export { validateElementResponse, validateBlockResponses, getFirstErrorMessage } from "./evaluator";
export { validators } from "./validators";
export type { TValidator, TValidatorCheckResult } from "./validators";

View File

@@ -0,0 +1,22 @@
import type { TFunction } from "i18next";
import { ZEmail } from "@formbricks/types/common";
import type { TResponseDataValue } from "@formbricks/types/responses";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TValidationRuleParamsEmail } from "@formbricks/types/surveys/validation-rules";
import type { TValidator, TValidatorCheckResult } from "./types";
export const emailValidator: TValidator<TValidationRuleParamsEmail> = {
check: (value: TResponseDataValue, _params: TValidationRuleParamsEmail, _element: TSurveyElement): TValidatorCheckResult => {
// Skip validation if value is empty (let required handle empty)
if (!value || typeof value !== "string" || value === "") {
return { valid: true };
}
return { valid: ZEmail.safeParse(value).success };
},
getDefaultMessage: (_params: TValidationRuleParamsEmail, t: TFunction): string => {
return t("errors.please_enter_a_valid_email_address");
},
};

View File

@@ -0,0 +1,34 @@
import type { TValidationRuleType } from "@formbricks/types/surveys/validation-rules";
import type { TValidator } from "./types";
import { requiredValidator } from "./required";
import { minLengthValidator } from "./min-length";
import { maxLengthValidator } from "./max-length";
import { emailValidator } from "./email";
import { urlValidator } from "./url";
import { phoneValidator } from "./phone";
import { patternValidator } from "./pattern";
import { minValueValidator } from "./min-value";
import { maxValueValidator } from "./max-value";
import { minSelectionsValidator } from "./min-selections";
import { maxSelectionsValidator } from "./max-selections";
/**
* Registry of all validators, keyed by rule type.
* Each validator implements the TValidator interface.
* We use `as TValidator` to work around TypeScript's strict generics for the discriminated union.
*/
export const validators: Record<TValidationRuleType, TValidator> = {
required: requiredValidator as TValidator,
minLength: minLengthValidator as TValidator,
maxLength: maxLengthValidator as TValidator,
email: emailValidator as TValidator,
url: urlValidator as TValidator,
phone: phoneValidator as TValidator,
pattern: patternValidator as TValidator,
minValue: minValueValidator as TValidator,
maxValue: maxValueValidator as TValidator,
minSelections: minSelectionsValidator as TValidator,
maxSelections: maxSelectionsValidator as TValidator,
};
export type { TValidator, TValidatorCheckResult } from "./types";

View File

@@ -0,0 +1,21 @@
import type { TFunction } from "i18next";
import type { TResponseDataValue } from "@formbricks/types/responses";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TValidationRuleParamsMaxLength } from "@formbricks/types/surveys/validation-rules";
import type { TValidator, TValidatorCheckResult } from "./types";
export const maxLengthValidator: TValidator<TValidationRuleParamsMaxLength> = {
check: (value: TResponseDataValue, params: TValidationRuleParamsMaxLength, _element: TSurveyElement): TValidatorCheckResult => {
// Skip validation if value is not a string
if (typeof value !== "string") {
return { valid: true };
}
return { valid: value.length <= params.max };
},
getDefaultMessage: (params: TValidationRuleParamsMaxLength, t: TFunction): string => {
return t("errors.max_length", { max: params.max });
},
};

View File

@@ -0,0 +1,24 @@
import type { TFunction } from "i18next";
import type { TResponseDataValue } from "@formbricks/types/responses";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TValidationRuleParamsMaxSelections } from "@formbricks/types/surveys/validation-rules";
import type { TValidator, TValidatorCheckResult } from "./types";
import { countSelections } from "./selection-utils";
export const maxSelectionsValidator: TValidator<TValidationRuleParamsMaxSelections> = {
check: (value: TResponseDataValue, params: TValidationRuleParamsMaxSelections, _element: TSurveyElement): TValidatorCheckResult => {
// If value is not an array, rule doesn't apply (graceful)
if (!Array.isArray(value)) {
return { valid: true };
}
const selectionCount = countSelections(value);
return { valid: selectionCount <= params.max };
},
getDefaultMessage: (params: TValidationRuleParamsMaxSelections, t: TFunction): string => {
return t("errors.max_selections", { max: params.max });
},
};

View File

@@ -0,0 +1,29 @@
import type { TFunction } from "i18next";
import type { TResponseDataValue } from "@formbricks/types/responses";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TValidationRuleParamsMaxValue } from "@formbricks/types/surveys/validation-rules";
import type { TValidator, TValidatorCheckResult } from "./types";
export const maxValueValidator: TValidator<TValidationRuleParamsMaxValue> = {
check: (value: TResponseDataValue, params: TValidationRuleParamsMaxValue, _element: TSurveyElement): TValidatorCheckResult => {
// Skip validation if value is empty (let required handle empty)
if (value === undefined || value === null || value === "") {
return { valid: true };
}
// Handle string numbers (from OpenText with inputType=number)
const numValue = typeof value === "string" ? parseFloat(value) : typeof value === "number" ? value : NaN;
if (isNaN(numValue)) {
return { valid: true }; // Let pattern/type validation handle non-numeric
}
return { valid: numValue <= params.max };
},
getDefaultMessage: (params: TValidationRuleParamsMaxValue, t: TFunction): string => {
return t("errors.max_value", { max: params.max });
},
};

View File

@@ -0,0 +1,21 @@
import type { TFunction } from "i18next";
import type { TResponseDataValue } from "@formbricks/types/responses";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TValidationRuleParamsMinLength } from "@formbricks/types/surveys/validation-rules";
import type { TValidator, TValidatorCheckResult } from "./types";
export const minLengthValidator: TValidator<TValidationRuleParamsMinLength> = {
check: (value: TResponseDataValue, params: TValidationRuleParamsMinLength, _element: TSurveyElement): TValidatorCheckResult => {
// Skip validation if value is not a string or is empty (let required handle empty)
if (typeof value !== "string" || value === "") {
return { valid: true };
}
return { valid: value.length >= params.min };
},
getDefaultMessage: (params: TValidationRuleParamsMinLength, t: TFunction): string => {
return t("errors.min_length", { min: params.min });
},
};

View File

@@ -0,0 +1,24 @@
import type { TFunction } from "i18next";
import type { TResponseDataValue } from "@formbricks/types/responses";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TValidationRuleParamsMinSelections } from "@formbricks/types/surveys/validation-rules";
import type { TValidator, TValidatorCheckResult } from "./types";
import { countSelections } from "./selection-utils";
export const minSelectionsValidator: TValidator<TValidationRuleParamsMinSelections> = {
check: (value: TResponseDataValue, params: TValidationRuleParamsMinSelections, _element: TSurveyElement): TValidatorCheckResult => {
// If value is not an array, check fails (need selections)
if (!Array.isArray(value)) {
return { valid: false };
}
const selectionCount = countSelections(value);
return { valid: selectionCount >= params.min };
},
getDefaultMessage: (params: TValidationRuleParamsMinSelections, t: TFunction): string => {
return t("errors.min_selections", { min: params.min });
},
};

View File

@@ -0,0 +1,29 @@
import type { TFunction } from "i18next";
import type { TResponseDataValue } from "@formbricks/types/responses";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TValidationRuleParamsMinValue } from "@formbricks/types/surveys/validation-rules";
import type { TValidator, TValidatorCheckResult } from "./types";
export const minValueValidator: TValidator<TValidationRuleParamsMinValue> = {
check: (value: TResponseDataValue, params: TValidationRuleParamsMinValue, _element: TSurveyElement): TValidatorCheckResult => {
// Skip validation if value is empty (let required handle empty)
if (value === undefined || value === null || value === "") {
return { valid: true };
}
// Handle string numbers (from OpenText with inputType=number)
const numValue = typeof value === "string" ? parseFloat(value) : typeof value === "number" ? value : NaN;
if (isNaN(numValue)) {
return { valid: true }; // Let pattern/type validation handle non-numeric
}
return { valid: numValue >= params.min };
},
getDefaultMessage: (params: TValidationRuleParamsMinValue, t: TFunction): string => {
return t("errors.min_value", { min: params.min });
},
};

View File

@@ -0,0 +1,29 @@
import type { TFunction } from "i18next";
import type { TResponseDataValue } from "@formbricks/types/responses";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TValidationRuleParamsPattern } from "@formbricks/types/surveys/validation-rules";
import type { TValidator, TValidatorCheckResult } from "./types";
export const patternValidator: TValidator<TValidationRuleParamsPattern> = {
check: (value: TResponseDataValue, params: TValidationRuleParamsPattern, _element: TSurveyElement): TValidatorCheckResult => {
// Skip validation if value is empty (let required handle empty)
if (!value || typeof value !== "string" || value === "") {
return { valid: true };
}
try {
const regex = new RegExp(params.pattern, params.flags);
return { valid: regex.test(value) };
} catch {
// If regex is invalid, consider it valid (design-time should catch this)
console.warn(`Invalid regex pattern: ${params.pattern}`);
return { valid: true };
}
},
getDefaultMessage: (_params: TValidationRuleParamsPattern, t: TFunction): string => {
return t("errors.invalid_format");
},
};

View File

@@ -0,0 +1,25 @@
import type { TFunction } from "i18next";
import type { TResponseDataValue } from "@formbricks/types/responses";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TValidationRuleParamsPhone } from "@formbricks/types/surveys/validation-rules";
import type { TValidator, TValidatorCheckResult } from "./types";
// Phone regex: must start with digit or +, end with digit
// Allows digits, +, -, and spaces in between
const PHONE_REGEX = /^[0-9+][0-9+\- ]*[0-9]$/;
export const phoneValidator: TValidator<TValidationRuleParamsPhone> = {
check: (value: TResponseDataValue, _params: TValidationRuleParamsPhone, _element: TSurveyElement): TValidatorCheckResult => {
// Skip validation if value is empty (let required handle empty)
if (!value || typeof value !== "string" || value === "") {
return { valid: true };
}
return { valid: PHONE_REGEX.test(value) };
},
getDefaultMessage: (_params: TValidationRuleParamsPhone, t: TFunction): string => {
return t("errors.please_enter_a_valid_phone_number");
},
};

View File

@@ -0,0 +1,24 @@
import type { TFunction } from "i18next";
import type { TResponseDataValue } from "@formbricks/types/responses";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TValidationRuleParamsRequired } from "@formbricks/types/surveys/validation-rules";
import type { TValidator, TValidatorCheckResult } from "./types";
export const requiredValidator: TValidator<TValidationRuleParamsRequired> = {
check: (value: TResponseDataValue, _params: TValidationRuleParamsRequired, _element: TSurveyElement): TValidatorCheckResult => {
const isEmpty =
value === undefined ||
value === null ||
value === "" ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === "object" && !Array.isArray(value) && Object.keys(value as object).length === 0);
return { valid: !isEmpty };
},
getDefaultMessage: (_params: TValidationRuleParamsRequired, t: TFunction): string => {
return t("errors.please_fill_out_this_field");
},
};

View File

@@ -0,0 +1,44 @@
/**
* Count the number of actual selections in a multi-select value array.
*
* The value array format for MultiSelect:
* - Regular options: ["Label1", "Label2"]
* - With "other" option: ["Label1", "", "custom other text"]
* - The "" sentinel indicates "other" is selected
* - The text following it is the custom value
*
* This function counts logical selections, not array length.
*/
export const countSelections = (value: unknown[]): number => {
if (!Array.isArray(value) || value.length === 0) {
return 0;
}
const hasOtherSentinel = value.includes("");
let count = 0;
for (let i = 0; i < value.length; i++) {
const item = value[i];
// Skip empty sentinel
if (item === "") {
continue;
}
// Skip the value immediately after empty sentinel (it's the "other" custom text)
if (i > 0 && value[i - 1] === "") {
continue;
}
count++;
}
// Add 1 for "other" if it's selected (the sentinel + optional text count as 1 selection)
if (hasOtherSentinel) {
count++;
}
return count;
};

View File

@@ -0,0 +1,29 @@
import type { TFunction } from "i18next";
import type { TResponseDataValue } from "@formbricks/types/responses";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TValidationRuleParams } from "@formbricks/types/surveys/validation-rules";
/**
* Result of a validator check
*/
export interface TValidatorCheckResult {
valid: boolean;
}
/**
* Generic validator interface
* P = the specific params type for this validator
*/
export interface TValidator<P extends TValidationRuleParams = TValidationRuleParams> {
/**
* Check if the value passes validation
*/
check: (value: TResponseDataValue, params: P, element: TSurveyElement) => TValidatorCheckResult;
/**
* Get the default error message for this rule
*/
getDefaultMessage: (params: P, t: TFunction) => string;
}

View File

@@ -0,0 +1,22 @@
import type { TFunction } from "i18next";
import { ZUrl } from "@formbricks/types/common";
import type { TResponseDataValue } from "@formbricks/types/responses";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type { TValidationRuleParamsUrl } from "@formbricks/types/surveys/validation-rules";
import type { TValidator, TValidatorCheckResult } from "./types";
export const urlValidator: TValidator<TValidationRuleParamsUrl> = {
check: (value: TResponseDataValue, _params: TValidationRuleParamsUrl, _element: TSurveyElement): TValidatorCheckResult => {
// Skip validation if value is empty (let required handle empty)
if (!value || typeof value !== "string" || value === "") {
return { valid: true };
}
return { valid: ZUrl.safeParse(value).success };
},
getDefaultMessage: (_params: TValidationRuleParamsUrl, t: TFunction): string => {
return t("errors.please_enter_a_valid_url");
},
};

View File

@@ -3,6 +3,7 @@ import { ZUrl } from "../common";
import { ZI18nString } from "../i18n";
import { ZAllowedFileExtension } from "../storage";
import { FORBIDDEN_IDS } from "./validation";
import { ZValidationRules } from "./validation-rules";
// Element Type Enum (same as question types)
export enum TSurveyElementTypeEnum {
@@ -61,6 +62,7 @@ export const ZSurveyElementBase = z.object({
scale: z.enum(["number", "smiley", "star"]).optional(),
range: z.union([z.literal(5), z.literal(3), z.literal(4), z.literal(7), z.literal(10)]).optional(),
isDraft: z.boolean().optional(),
validationRules: ZValidationRules.optional(),
});
// OpenText Element

View File

@@ -0,0 +1,159 @@
import { z } from "zod";
import { ZI18nString } from "../i18n";
// Validation rule type enum - extensible for future rule types
export const ZValidationRuleType = z.enum([
// Universal rules
"required",
// Text/OpenText rules
"minLength",
"maxLength",
"pattern",
"email",
"url",
"phone",
// Numeric rules (for OpenText inputType=number)
"minValue",
"maxValue",
// Selection rules (MultiSelect, PictureSelection)
"minSelections",
"maxSelections",
]);
export type TValidationRuleType = z.infer<typeof ZValidationRuleType>;
// Rule params - discriminated union for type-safe params per rule type
export const ZValidationRuleParamsRequired = z.object({
type: z.literal("required"),
});
export const ZValidationRuleParamsMinLength = z.object({
type: z.literal("minLength"),
min: z.number().min(0),
});
export const ZValidationRuleParamsMaxLength = z.object({
type: z.literal("maxLength"),
max: z.number().min(1),
});
export const ZValidationRuleParamsPattern = z.object({
type: z.literal("pattern"),
pattern: z.string().min(1),
flags: z.string().optional(),
});
export const ZValidationRuleParamsEmail = z.object({
type: z.literal("email"),
});
export const ZValidationRuleParamsUrl = z.object({
type: z.literal("url"),
});
export const ZValidationRuleParamsPhone = z.object({
type: z.literal("phone"),
});
export const ZValidationRuleParamsMinValue = z.object({
type: z.literal("minValue"),
min: z.number(),
});
export const ZValidationRuleParamsMaxValue = z.object({
type: z.literal("maxValue"),
max: z.number(),
});
export const ZValidationRuleParamsMinSelections = z.object({
type: z.literal("minSelections"),
min: z.number().min(1),
});
export const ZValidationRuleParamsMaxSelections = z.object({
type: z.literal("maxSelections"),
max: z.number().min(1),
});
// Union of all params types
export const ZValidationRuleParams = z.discriminatedUnion("type", [
ZValidationRuleParamsRequired,
ZValidationRuleParamsMinLength,
ZValidationRuleParamsMaxLength,
ZValidationRuleParamsPattern,
ZValidationRuleParamsEmail,
ZValidationRuleParamsUrl,
ZValidationRuleParamsPhone,
ZValidationRuleParamsMinValue,
ZValidationRuleParamsMaxValue,
ZValidationRuleParamsMinSelections,
ZValidationRuleParamsMaxSelections,
]);
export type TValidationRuleParams = z.infer<typeof ZValidationRuleParams>;
// Extract specific param types for validators
export type TValidationRuleParamsRequired = z.infer<typeof ZValidationRuleParamsRequired>;
export type TValidationRuleParamsMinLength = z.infer<typeof ZValidationRuleParamsMinLength>;
export type TValidationRuleParamsMaxLength = z.infer<typeof ZValidationRuleParamsMaxLength>;
export type TValidationRuleParamsPattern = z.infer<typeof ZValidationRuleParamsPattern>;
export type TValidationRuleParamsEmail = z.infer<typeof ZValidationRuleParamsEmail>;
export type TValidationRuleParamsUrl = z.infer<typeof ZValidationRuleParamsUrl>;
export type TValidationRuleParamsPhone = z.infer<typeof ZValidationRuleParamsPhone>;
export type TValidationRuleParamsMinValue = z.infer<typeof ZValidationRuleParamsMinValue>;
export type TValidationRuleParamsMaxValue = z.infer<typeof ZValidationRuleParamsMaxValue>;
export type TValidationRuleParamsMinSelections = z.infer<typeof ZValidationRuleParamsMinSelections>;
export type TValidationRuleParamsMaxSelections = z.infer<typeof ZValidationRuleParamsMaxSelections>;
// Validation rule stored on element
export const ZValidationRule = z.object({
id: z.string(),
params: ZValidationRuleParams,
customErrorMessage: ZI18nString.optional(),
enabled: z.boolean().default(true),
});
export type TValidationRule = z.infer<typeof ZValidationRule>;
// Array of validation rules
export const ZValidationRules = z.array(ZValidationRule);
export type TValidationRules = z.infer<typeof ZValidationRules>;
// Validation error returned by evaluator
export interface TValidationError {
ruleId: string;
ruleType: TValidationRuleType;
message: string;
}
// Validation result for a single element
export interface TValidationResult {
valid: boolean;
errors: TValidationError[];
}
// Error map for block-level validation (keyed by elementId)
export type TValidationErrorMap = Record<string, TValidationError[]>;
// Applicable rules per element type
export const APPLICABLE_RULES: Record<string, TValidationRuleType[]> = {
openText: ["required", "minLength", "maxLength", "pattern", "email", "url", "phone", "minValue", "maxValue"],
multipleChoiceSingle: ["required"],
multipleChoiceMulti: ["required", "minSelections", "maxSelections"],
rating: ["required"],
nps: ["required"],
date: ["required"],
consent: ["required"],
matrix: ["required"],
ranking: ["required"],
fileUpload: ["required"],
pictureSelection: ["required", "minSelections", "maxSelections"],
address: ["required"],
contactInfo: ["required"],
cal: ["required"],
cta: [], // CTA never validates
};