mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-03 21:59:38 -06:00
Compare commits
1 Commits
typeerror-
...
validation
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c092f005b6 |
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -44,8 +44,15 @@
|
||||
},
|
||||
"invalid_device_error": {
|
||||
"message": "Please disable spam protection in the survey settings to continue using this device.",
|
||||
"title": "This device doesn’t 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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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:
|
||||
|
||||
151
packages/surveys/src/lib/validation/evaluator.ts
Normal file
151
packages/surveys/src/lib/validation/evaluator.ts
Normal 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;
|
||||
};
|
||||
|
||||
|
||||
5
packages/surveys/src/lib/validation/index.ts
Normal file
5
packages/surveys/src/lib/validation/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { validateElementResponse, validateBlockResponses, getFirstErrorMessage } from "./evaluator";
|
||||
export { validators } from "./validators";
|
||||
export type { TValidator, TValidatorCheckResult } from "./validators";
|
||||
|
||||
|
||||
22
packages/surveys/src/lib/validation/validators/email.ts
Normal file
22
packages/surveys/src/lib/validation/validators/email.ts
Normal 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");
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
34
packages/surveys/src/lib/validation/validators/index.ts
Normal file
34
packages/surveys/src/lib/validation/validators/index.ts
Normal 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";
|
||||
21
packages/surveys/src/lib/validation/validators/max-length.ts
Normal file
21
packages/surveys/src/lib/validation/validators/max-length.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
29
packages/surveys/src/lib/validation/validators/max-value.ts
Normal file
29
packages/surveys/src/lib/validation/validators/max-value.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
21
packages/surveys/src/lib/validation/validators/min-length.ts
Normal file
21
packages/surveys/src/lib/validation/validators/min-length.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
29
packages/surveys/src/lib/validation/validators/min-value.ts
Normal file
29
packages/surveys/src/lib/validation/validators/min-value.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
29
packages/surveys/src/lib/validation/validators/pattern.ts
Normal file
29
packages/surveys/src/lib/validation/validators/pattern.ts
Normal 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");
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
25
packages/surveys/src/lib/validation/validators/phone.ts
Normal file
25
packages/surveys/src/lib/validation/validators/phone.ts
Normal 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");
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
24
packages/surveys/src/lib/validation/validators/required.ts
Normal file
24
packages/surveys/src/lib/validation/validators/required.ts
Normal 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");
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
29
packages/surveys/src/lib/validation/validators/types.ts
Normal file
29
packages/surveys/src/lib/validation/validators/types.ts
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
22
packages/surveys/src/lib/validation/validators/url.ts
Normal file
22
packages/surveys/src/lib/validation/validators/url.ts
Normal 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");
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
159
packages/types/surveys/validation-rules.ts
Normal file
159
packages/types/surveys/validation-rules.ts
Normal 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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user