mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-21 00:58:29 -06:00
Compare commits
1 Commits
cursor/cus
...
validation
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c092f005b6 |
@@ -1588,6 +1588,8 @@
|
|||||||
"upload_at_least_2_images": "Upload at least 2 images",
|
"upload_at_least_2_images": "Upload at least 2 images",
|
||||||
"upper_label": "Upper Label",
|
"upper_label": "Upper Label",
|
||||||
"url_filters": "URL Filters",
|
"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",
|
"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_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",
|
"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 { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
|
||||||
import { TI18nString } from "@formbricks/types/i18n";
|
import { TI18nString } from "@formbricks/types/i18n";
|
||||||
import { TSurveyElementTypeEnum, TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
|
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 { TShuffleOption, TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||||
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
|
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
|
||||||
import { BulkEditOptionsModal } from "@/modules/survey/editor/components/bulk-edit-options-modal";
|
import { BulkEditOptionsModal } from "@/modules/survey/editor/components/bulk-edit-options-modal";
|
||||||
import { ElementOptionChoice } from "@/modules/survey/editor/components/element-option-choice";
|
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 { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Label } from "@/modules/ui/components/label";
|
import { Label } from "@/modules/ui/components/label";
|
||||||
@@ -398,6 +400,19 @@ export const MultipleChoiceElementForm = ({
|
|||||||
surveyLanguageCodes={surveyLanguageCodes}
|
surveyLanguageCodes={surveyLanguageCodes}
|
||||||
locale={locale}
|
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>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,11 +4,17 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
|||||||
import { HashIcon, LinkIcon, MailIcon, MessageSquareTextIcon, PhoneIcon, PlusIcon } from "lucide-react";
|
import { HashIcon, LinkIcon, MailIcon, MessageSquareTextIcon, PhoneIcon, PlusIcon } from "lucide-react";
|
||||||
import { JSX, useEffect, useState } from "react";
|
import { JSX, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||||
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
|
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 { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Input } from "@/modules/ui/components/input";
|
import { Input } from "@/modules/ui/components/input";
|
||||||
@@ -245,6 +251,17 @@ export const OpenElementForm = ({
|
|||||||
customContainerClass="p-0"
|
customContainerClass="p-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Validation Rules Editor */}
|
||||||
|
<ValidationRulesEditor
|
||||||
|
elementType={TSurveyElementTypeEnum.OpenText}
|
||||||
|
validationRules={element.validationRules ?? []}
|
||||||
|
onUpdateRules={(rules: TValidationRule[]) => {
|
||||||
|
updateElement(elementIdx, {
|
||||||
|
validationRules: rules,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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": {
|
"invalid_device_error": {
|
||||||
"message": "Please disable spam protection in the survey settings to continue using this device.",
|
"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_book_an_appointment": "Please book an appointment",
|
||||||
"please_enter_a_valid_email_address": "Please enter a valid email address",
|
"please_enter_a_valid_email_address": "Please enter a valid email address",
|
||||||
"please_enter_a_valid_phone_number": "Please enter a valid phone number",
|
"please_enter_a_valid_phone_number": "Please enter a valid phone number",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface MultipleChoiceMultiElementProps {
|
|||||||
autoFocusEnabled: boolean;
|
autoFocusEnabled: boolean;
|
||||||
currentElementId: string;
|
currentElementId: string;
|
||||||
dir?: "ltr" | "rtl" | "auto";
|
dir?: "ltr" | "rtl" | "auto";
|
||||||
|
errorMessage?: string; // Validation error from centralized validation
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MultipleChoiceMultiElement({
|
export function MultipleChoiceMultiElement({
|
||||||
@@ -28,10 +29,10 @@ export function MultipleChoiceMultiElement({
|
|||||||
setTtc,
|
setTtc,
|
||||||
currentElementId,
|
currentElementId,
|
||||||
dir = "auto",
|
dir = "auto",
|
||||||
|
errorMessage,
|
||||||
}: Readonly<MultipleChoiceMultiElementProps>) {
|
}: Readonly<MultipleChoiceMultiElementProps>) {
|
||||||
const [startTime, setStartTime] = useState(performance.now());
|
const [startTime, setStartTime] = useState(performance.now());
|
||||||
const [otherValue, setOtherValue] = useState("");
|
const [otherValue, setOtherValue] = useState("");
|
||||||
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
|
|
||||||
const isCurrent = element.id === currentElementId;
|
const isCurrent = element.id === currentElementId;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
||||||
@@ -173,22 +174,9 @@ export function MultipleChoiceMultiElement({
|
|||||||
onChange({ [element.id]: nextValue });
|
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) => {
|
const handleSubmit = (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setErrorMessage(undefined);
|
// Update TTC when form is submitted (for TTC collection)
|
||||||
if (!validateRequired()) return;
|
|
||||||
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
const updatedTtcObj = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||||
setTtc(updatedTtcObj);
|
setTtc(updatedTtcObj);
|
||||||
};
|
};
|
||||||
@@ -228,7 +216,6 @@ export function MultipleChoiceMultiElement({
|
|||||||
|
|
||||||
// Handle selection changes - store labels directly instead of IDs
|
// Handle selection changes - store labels directly instead of IDs
|
||||||
const handleMultiSelectChange = (selectedIds: string[]) => {
|
const handleMultiSelectChange = (selectedIds: string[]) => {
|
||||||
setErrorMessage(undefined);
|
|
||||||
const nextLabels: string[] = [];
|
const nextLabels: string[] = [];
|
||||||
const isOtherNowSelected = Boolean(otherOption) && selectedIds.includes(otherOption!.id);
|
const isOtherNowSelected = Boolean(otherOption) && selectedIds.includes(otherOption!.id);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useState } from "preact/hooks";
|
import { useState } from "preact/hooks";
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { OpenText } from "@formbricks/survey-ui";
|
import { OpenText } from "@formbricks/survey-ui";
|
||||||
import { ZEmail, ZUrl } from "@formbricks/types/common";
|
|
||||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||||
import type { TSurveyOpenTextElement } from "@formbricks/types/surveys/elements";
|
import type { TSurveyOpenTextElement } from "@formbricks/types/surveys/elements";
|
||||||
import { getLocalizedValue } from "@/lib/i18n";
|
import { getLocalizedValue } from "@/lib/i18n";
|
||||||
@@ -18,6 +16,7 @@ interface OpenTextElementProps {
|
|||||||
autoFocusEnabled: boolean;
|
autoFocusEnabled: boolean;
|
||||||
currentElementId: string;
|
currentElementId: string;
|
||||||
dir?: "ltr" | "rtl" | "auto";
|
dir?: "ltr" | "rtl" | "auto";
|
||||||
|
errorMessage?: string; // Validation error from centralized validation
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OpenTextElement({
|
export function OpenTextElement({
|
||||||
@@ -29,76 +28,19 @@ export function OpenTextElement({
|
|||||||
setTtc,
|
setTtc,
|
||||||
currentElementId,
|
currentElementId,
|
||||||
dir = "auto",
|
dir = "auto",
|
||||||
|
errorMessage,
|
||||||
}: Readonly<OpenTextElementProps>) {
|
}: Readonly<OpenTextElementProps>) {
|
||||||
const [startTime, setStartTime] = useState(performance.now());
|
const [startTime, setStartTime] = useState(performance.now());
|
||||||
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
|
|
||||||
const isCurrent = element.id === currentElementId;
|
const isCurrent = element.id === currentElementId;
|
||||||
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
useTtc(element.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const handleChange = (inputValue: string) => {
|
const handleChange = (inputValue: string) => {
|
||||||
// Clear error when user starts typing
|
|
||||||
setErrorMessage(undefined);
|
|
||||||
onChange({ [element.id]: inputValue });
|
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) => {
|
const handleOnSubmit = (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setErrorMessage(undefined);
|
// Update TTC when form is submitted (for TTC collection)
|
||||||
|
|
||||||
if (!validateRequired()) return;
|
|
||||||
if (!validateInput()) return;
|
|
||||||
|
|
||||||
const updatedTtc = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
const updatedTtc = getUpdatedTtc(ttc, element.id, performance.now() - startTime);
|
||||||
setTtc(updatedTtc);
|
setTtc(updatedTtc);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "preact/hooks";
|
import { useEffect, useRef, useState } from "preact/hooks";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { type TJsFileUploadParams } from "@formbricks/types/js";
|
import { type TJsFileUploadParams } from "@formbricks/types/js";
|
||||||
import { type TResponseData, TResponseDataValue, type TResponseTtc } from "@formbricks/types/responses";
|
import { type TResponseData, TResponseDataValue, type TResponseTtc } from "@formbricks/types/responses";
|
||||||
import { type TUploadFileConfig } from "@formbricks/types/storage";
|
import { type TUploadFileConfig } from "@formbricks/types/storage";
|
||||||
@@ -9,12 +10,14 @@ import {
|
|||||||
TSurveyMatrixElement,
|
TSurveyMatrixElement,
|
||||||
TSurveyRankingElement,
|
TSurveyRankingElement,
|
||||||
} from "@formbricks/types/surveys/elements";
|
} from "@formbricks/types/surveys/elements";
|
||||||
|
import { TValidationErrorMap } from "@formbricks/types/surveys/validation-rules";
|
||||||
import { BackButton } from "@/components/buttons/back-button";
|
import { BackButton } from "@/components/buttons/back-button";
|
||||||
import { SubmitButton } from "@/components/buttons/submit-button";
|
import { SubmitButton } from "@/components/buttons/submit-button";
|
||||||
import { ElementConditional } from "@/components/general/element-conditional";
|
import { ElementConditional } from "@/components/general/element-conditional";
|
||||||
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
|
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
|
||||||
import { getLocalizedValue } from "@/lib/i18n";
|
import { getLocalizedValue } from "@/lib/i18n";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { validateBlockResponses, getFirstErrorMessage } from "@/lib/validation";
|
||||||
|
|
||||||
interface BlockConditionalProps {
|
interface BlockConditionalProps {
|
||||||
block: TSurveyBlock;
|
block: TSurveyBlock;
|
||||||
@@ -59,9 +62,14 @@ export function BlockConditional({
|
|||||||
dir,
|
dir,
|
||||||
fullSizeCards,
|
fullSizeCards,
|
||||||
}: BlockConditionalProps) {
|
}: BlockConditionalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Track the current element being filled (for TTC tracking)
|
// Track the current element being filled (for TTC tracking)
|
||||||
const [currentElementId, setCurrentElementId] = useState(block.elements[0]?.id);
|
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
|
// Refs to store form elements for each element so we can trigger their validation
|
||||||
const elementFormRefs = useRef<Map<string, HTMLFormElement>>(new Map());
|
const elementFormRefs = useRef<Map<string, HTMLFormElement>>(new Map());
|
||||||
|
|
||||||
@@ -74,6 +82,14 @@ export function BlockConditional({
|
|||||||
if (elementId !== currentElementId) {
|
if (elementId !== currentElementId) {
|
||||||
setCurrentElementId(elementId);
|
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
|
// Merge with existing block data to preserve other element values
|
||||||
onChange({ ...value, ...responseData });
|
onChange({ ...value, ...responseData });
|
||||||
};
|
};
|
||||||
@@ -263,15 +279,34 @@ export function BlockConditional({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate all forms and check for custom validation rules
|
// Run centralized validation for elements that support it (OpenText, MultiSelect)
|
||||||
const firstInvalidForm = findFirstInvalidForm();
|
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) {
|
if (firstInvalidForm) {
|
||||||
firstInvalidForm.scrollIntoView({ behavior: "smooth", block: "center" });
|
firstInvalidForm.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear any previous errors
|
||||||
|
setElementErrors({});
|
||||||
|
|
||||||
// Collect TTC and responses, then submit
|
// Collect TTC and responses, then submit
|
||||||
const blockTtc = collectTtcValues();
|
const blockTtc = collectTtcValues();
|
||||||
const blockResponses = collectBlockResponses();
|
const blockResponses = collectBlockResponses();
|
||||||
@@ -310,6 +345,7 @@ export function BlockConditional({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onTtcCollect={handleTtcCollect}
|
onTtcCollect={handleTtcCollect}
|
||||||
|
errorMessage={getFirstErrorMessage(elementErrors, element.id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ interface ElementConditionalProps {
|
|||||||
dir?: "ltr" | "rtl" | "auto";
|
dir?: "ltr" | "rtl" | "auto";
|
||||||
formRef?: (ref: HTMLFormElement | null) => void; // Callback to expose the form element
|
formRef?: (ref: HTMLFormElement | null) => void; // Callback to expose the form element
|
||||||
onTtcCollect?: (elementId: string, ttc: number) => void; // Callback to collect TTC synchronously
|
onTtcCollect?: (elementId: string, ttc: number) => void; // Callback to collect TTC synchronously
|
||||||
|
errorMessage?: string; // Validation error message from centralized validation
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ElementConditional({
|
export function ElementConditional({
|
||||||
@@ -56,6 +57,7 @@ export function ElementConditional({
|
|||||||
dir,
|
dir,
|
||||||
formRef,
|
formRef,
|
||||||
onTtcCollect,
|
onTtcCollect,
|
||||||
|
errorMessage,
|
||||||
}: ElementConditionalProps) {
|
}: ElementConditionalProps) {
|
||||||
// Ref to the container div, used to find and expose the form element inside
|
// Ref to the container div, used to find and expose the form element inside
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -124,6 +126,7 @@ export function ElementConditional({
|
|||||||
autoFocusEnabled={autoFocusEnabled}
|
autoFocusEnabled={autoFocusEnabled}
|
||||||
currentElementId={currentElementId}
|
currentElementId={currentElementId}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
|
errorMessage={errorMessage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
||||||
@@ -154,6 +157,7 @@ export function ElementConditional({
|
|||||||
autoFocusEnabled={autoFocusEnabled}
|
autoFocusEnabled={autoFocusEnabled}
|
||||||
currentElementId={currentElementId}
|
currentElementId={currentElementId}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
|
errorMessage={errorMessage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case TSurveyElementTypeEnum.NPS:
|
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 { ZI18nString } from "../i18n";
|
||||||
import { ZAllowedFileExtension } from "../storage";
|
import { ZAllowedFileExtension } from "../storage";
|
||||||
import { FORBIDDEN_IDS } from "./validation";
|
import { FORBIDDEN_IDS } from "./validation";
|
||||||
|
import { ZValidationRules } from "./validation-rules";
|
||||||
|
|
||||||
// Element Type Enum (same as question types)
|
// Element Type Enum (same as question types)
|
||||||
export enum TSurveyElementTypeEnum {
|
export enum TSurveyElementTypeEnum {
|
||||||
@@ -61,6 +62,7 @@ export const ZSurveyElementBase = z.object({
|
|||||||
scale: z.enum(["number", "smiley", "star"]).optional(),
|
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(),
|
range: z.union([z.literal(5), z.literal(3), z.literal(4), z.literal(7), z.literal(10)]).optional(),
|
||||||
isDraft: z.boolean().optional(),
|
isDraft: z.boolean().optional(),
|
||||||
|
validationRules: ZValidationRules.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// OpenText Element
|
// 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