broke validation rule editor into smaller components

This commit is contained in:
Dhruwang
2026-01-12 18:01:14 +05:30
parent 4477006cfb
commit 539e7a0fc3
9 changed files with 780 additions and 417 deletions

View File

@@ -0,0 +1,34 @@
"use client";
import { useTranslation } from "react-i18next";
import { TValidationLogic } from "@formbricks/types/surveys/elements";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
interface ValidationLogicSelectorProps {
value: TValidationLogic;
onChange: (value: TValidationLogic) => void;
}
export const ValidationLogicSelector = ({ value, onChange }: ValidationLogicSelectorProps) => {
const { t } = useTranslation();
return (
<div className="flex w-full items-center gap-2">
<Select value={value} onValueChange={(val) => onChange(val as TValidationLogic)}>
<SelectTrigger className="h-8 w-fit bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="and">{t("environments.surveys.edit.validation_logic_and")}</SelectItem>
<SelectItem value="or">{t("environments.surveys.edit.validation_logic_or")}</SelectItem>
</SelectContent>
</Select>
</div>
);
};

View File

@@ -0,0 +1,42 @@
"use client";
import { useTranslation } from "react-i18next";
import { TAddressField, TContactInfoField } from "@formbricks/types/surveys/validation-rules";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
interface ValidationRuleFieldSelectorProps {
value: TAddressField | TContactInfoField | undefined;
onChange: (value: TAddressField | TContactInfoField | undefined) => void;
fieldOptions: { value: TAddressField | TContactInfoField; label: string }[];
}
export const ValidationRuleFieldSelector = ({
value,
onChange,
fieldOptions,
}: ValidationRuleFieldSelectorProps) => {
const { t } = useTranslation();
return (
<Select
value={value ?? ""}
onValueChange={(val) => onChange(val ? (val as TAddressField | TContactInfoField) : undefined)}>
<SelectTrigger className="h-9 min-w-[140px] bg-white">
<SelectValue placeholder={t("environments.surveys.edit.select_field")} />
</SelectTrigger>
<SelectContent>
{fieldOptions.map((field) => (
<SelectItem key={field.value} value={field.value}>
{field.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
};

View File

@@ -0,0 +1,47 @@
"use client";
import { TSurveyOpenTextElementInputType } from "@formbricks/types/surveys/elements";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { cn } from "@/modules/ui/lib/utils";
// Reusable input type options for OpenText elements
const INPUT_TYPE_OPTIONS = (
<>
<SelectItem value="text">{"Text"}</SelectItem>
<SelectItem value="email">{"Email"}</SelectItem>
<SelectItem value="url">{"Url"}</SelectItem>
<SelectItem value="phone">{"Phone"}</SelectItem>
<SelectItem value="number">{"Number"}</SelectItem>
</>
);
interface ValidationRuleInputTypeSelectorProps {
value: TSurveyOpenTextElementInputType;
onChange?: (value: TSurveyOpenTextElementInputType) => void;
disabled?: boolean;
}
export const ValidationRuleInputTypeSelector = ({
value,
onChange,
disabled = false,
}: ValidationRuleInputTypeSelectorProps) => {
return (
<Select
value={value}
onValueChange={onChange ? (val) => onChange(val as TSurveyOpenTextElementInputType) : undefined}
disabled={disabled}>
<SelectTrigger
className={cn("h-9 min-w-[120px]", disabled ? "cursor-not-allowed bg-slate-100" : "bg-white")}>
<SelectValue />
</SelectTrigger>
<SelectContent>{INPUT_TYPE_OPTIONS}</SelectContent>
</Select>
);
};

View File

@@ -0,0 +1,180 @@
"use client";
import { PlusIcon, TrashIcon } from "lucide-react";
import { TAllowedFileExtension } from "@formbricks/types/storage";
import {
TSurveyElement,
TSurveyElementTypeEnum,
TSurveyOpenTextElementInputType,
} from "@formbricks/types/surveys/elements";
import {
TAddressField,
TContactInfoField,
TValidationRule,
TValidationRuleType,
} from "@formbricks/types/surveys/validation-rules";
import { Button } from "@/modules/ui/components/button";
import { RULE_TYPE_CONFIG } from "../lib/validation-rules-config";
import { getAvailableRuleTypes, getRuleValue } from "../lib/validation-rules-utils";
import { ValidationRuleFieldSelector } from "./validation-rule-field-selector";
import { ValidationRuleInputTypeSelector } from "./validation-rule-input-type-selector";
import { ValidationRuleTypeSelector } from "./validation-rule-type-selector";
import { ValidationRuleUnitSelector } from "./validation-rule-unit-selector";
import { ValidationRuleValueInput } from "./validation-rule-value-input";
interface ValidationRuleRowProps {
rule: TValidationRule;
index: number;
elementType: TSurveyElementTypeEnum;
element?: TSurveyElement;
inputType?: TSurveyOpenTextElementInputType;
onInputTypeChange?: (inputType: TSurveyOpenTextElementInputType) => void;
fieldOptions: { value: TAddressField | TContactInfoField; label: string }[];
needsFieldSelector: boolean;
validationRules: TValidationRule[];
effectiveMaxSizeInMB?: number;
ruleLabels: Record<string, string>;
onFieldChange: (ruleId: string, field: TAddressField | TContactInfoField | undefined) => void;
onRuleTypeChange: (ruleId: string, newType: TValidationRuleType) => void;
onRuleValueChange: (ruleId: string, value: string) => void;
onFileExtensionChange: (ruleId: string, extensions: TAllowedFileExtension[]) => void;
onFileSizeUnitChange: (ruleId: string, unit: "KB" | "MB") => void;
onDelete: (ruleId: string) => void;
onAdd: (insertAfterIndex: number) => void;
canAddMore: boolean;
}
export const ValidationRuleRow = ({
rule,
index,
elementType,
element,
inputType,
onInputTypeChange,
fieldOptions,
needsFieldSelector,
validationRules,
effectiveMaxSizeInMB,
ruleLabels,
onFieldChange,
onRuleTypeChange,
onRuleValueChange,
onFileExtensionChange,
onFileSizeUnitChange,
onDelete,
onAdd,
canAddMore,
}: ValidationRuleRowProps) => {
const ruleType = rule.type;
const config = RULE_TYPE_CONFIG[ruleType];
const currentValue = getRuleValue(rule);
// Get available types for this rule (current type + unused types, no duplicates)
// For address/contact info, filter by selected field
const ruleField = rule.field;
const otherAvailableTypes = getAvailableRuleTypes(
elementType,
validationRules.filter((r) => r.id !== rule.id),
elementType === TSurveyElementTypeEnum.OpenText ? inputType : undefined,
ruleField
).filter((t) => t !== ruleType);
const availableTypesForSelect = [ruleType, ...otherAvailableTypes];
// Check if this is OpenText and first rule - show input type selector
const isOpenText = elementType === TSurveyElementTypeEnum.OpenText;
const isFirstRule = index === 0;
const showInputTypeSelector = isOpenText && isFirstRule;
const handleFileExtensionChange = (extensions: TAllowedFileExtension[]) => {
onFileExtensionChange(rule.id, extensions);
};
return (
<div className="flex w-full items-center gap-2">
{/* Field Selector (for Address and Contact Info elements) */}
{needsFieldSelector && (
<ValidationRuleFieldSelector
value={rule.field}
onChange={(value) => onFieldChange(rule.id, value)}
fieldOptions={fieldOptions}
/>
)}
{/* Input Type Selector (only for OpenText, first rule) */}
{showInputTypeSelector && inputType !== undefined && onInputTypeChange && (
<ValidationRuleInputTypeSelector value={inputType} onChange={onInputTypeChange} />
)}
{/* Input Type Display (disabled, for subsequent rules) */}
{isOpenText && !isFirstRule && inputType !== undefined && (
<ValidationRuleInputTypeSelector value={inputType} disabled />
)}
{/* Rule Type Selector */}
<ValidationRuleTypeSelector
value={ruleType}
onChange={(value) => onRuleTypeChange(rule.id, value)}
availableTypes={availableTypesForSelect}
ruleLabels={ruleLabels}
needsValue={config.needsValue}
/>
{/* Value Input (if needed) */}
{config.needsValue && (
<div className="flex w-full items-center gap-2">
<ValidationRuleValueInput
rule={rule}
ruleType={ruleType}
config={config}
currentValue={currentValue}
onChange={(value) => onRuleValueChange(rule.id, value)}
onFileExtensionChange={handleFileExtensionChange}
element={element}
effectiveMaxSizeInMB={effectiveMaxSizeInMB}
/>
{/* Unit selector (if applicable) */}
{config.unitOptions && config.unitOptions.length > 0 && (
<ValidationRuleUnitSelector
value={
ruleType === "fileSizeAtLeast" || ruleType === "fileSizeAtMost"
? (rule.params as { size: number; unit: "KB" | "MB" }).unit
: config.unitOptions[0].value
}
onChange={
ruleType === "fileSizeAtLeast" || ruleType === "fileSizeAtMost"
? (value) => onFileSizeUnitChange(rule.id, value as "KB" | "MB")
: undefined
}
unitOptions={config.unitOptions}
ruleLabels={ruleLabels}
disabled={config.unitOptions.length === 1}
/>
)}
</div>
)}
{/* Delete button */}
<Button
variant="outline"
size="icon"
type="button"
onClick={() => onDelete(rule.id)}
className="shrink-0 bg-white">
<TrashIcon className="h-4 w-4" />
</Button>
{/* Add button */}
{canAddMore && (
<Button
variant="outline"
size="icon"
type="button"
onClick={() => onAdd(index)}
className="shrink-0 bg-white">
<PlusIcon className="h-4 w-4" />
</Button>
)}
</div>
);
};

View File

@@ -0,0 +1,44 @@
"use client";
import { capitalize } from "lodash";
import { TValidationRuleType } from "@formbricks/types/surveys/validation-rules";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { cn } from "@/modules/ui/lib/utils";
import { RULE_TYPE_CONFIG } from "../lib/validation-rules-config";
interface ValidationRuleTypeSelectorProps {
value: TValidationRuleType;
onChange: (value: TValidationRuleType) => void;
availableTypes: TValidationRuleType[];
ruleLabels: Record<string, string>;
needsValue: boolean;
}
export const ValidationRuleTypeSelector = ({
value,
onChange,
availableTypes,
ruleLabels,
needsValue,
}: ValidationRuleTypeSelectorProps) => {
return (
<Select value={value} onValueChange={(val) => onChange(val as TValidationRuleType)}>
<SelectTrigger className={cn("bg-white", needsValue ? "min-w-[200px]" : "flex-1")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableTypes.map((type) => (
<SelectItem key={type} value={type}>
{capitalize(ruleLabels[RULE_TYPE_CONFIG[type].labelKey])}
</SelectItem>
))}
</SelectContent>
</Select>
);
};

View File

@@ -0,0 +1,46 @@
"use client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { cn } from "@/modules/ui/lib/utils";
interface UnitOption {
value: string;
labelKey: string;
}
interface ValidationRuleUnitSelectorProps {
value: string;
onChange?: (value: string) => void;
unitOptions: UnitOption[];
ruleLabels: Record<string, string>;
disabled?: boolean;
}
export const ValidationRuleUnitSelector = ({
value,
onChange,
unitOptions,
ruleLabels,
disabled = false,
}: ValidationRuleUnitSelectorProps) => {
return (
<Select value={value} onValueChange={onChange} disabled={disabled || unitOptions.length === 1}>
<SelectTrigger className={cn("flex-1 bg-white", disabled && "cursor-not-allowed")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{unitOptions.map((unit) => (
<SelectItem key={unit.value} value={unit.value}>
{ruleLabels[unit.labelKey]}
</SelectItem>
))}
</SelectContent>
</Select>
);
};

View File

@@ -0,0 +1,139 @@
"use client";
import { useTranslation } from "react-i18next";
import { ALLOWED_FILE_EXTENSIONS, TAllowedFileExtension } from "@formbricks/types/storage";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TValidationRule, TValidationRuleType } from "@formbricks/types/surveys/validation-rules";
import { Input } from "@/modules/ui/components/input";
import { MultiSelect } from "@/modules/ui/components/multi-select";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { RULE_TYPE_CONFIG } from "../lib/validation-rules-config";
interface ValidationRuleValueInputProps {
rule: TValidationRule;
ruleType: TValidationRuleType;
config: (typeof RULE_TYPE_CONFIG)[TValidationRuleType];
currentValue: number | string | undefined;
onChange: (value: string) => void;
onFileExtensionChange: (extensions: TAllowedFileExtension[]) => void;
element?: TSurveyElement;
effectiveMaxSizeInMB?: number;
}
export const ValidationRuleValueInput = ({
rule,
ruleType,
config,
currentValue,
onChange,
onFileExtensionChange,
element,
effectiveMaxSizeInMB,
}: ValidationRuleValueInputProps) => {
const { t } = useTranslation();
// Determine HTML input type for value inputs
let htmlInputType: "number" | "date" | "text" = "text";
if (config.valueType === "number") {
htmlInputType = "number";
} else if (
ruleType.startsWith("is") &&
(ruleType.includes("Later") || ruleType.includes("Earlier") || ruleType.includes("On"))
) {
htmlInputType = "date";
}
// Special handling for date range inputs
if (ruleType === "isBetween" || ruleType === "isNotBetween") {
return (
<div className="flex w-full items-center gap-2">
<Input
type="date"
value={(currentValue as string)?.split(",")?.[0] ?? ""}
onChange={(e) => {
const currentEndDate = (currentValue as string)?.split(",")?.[1] ?? "";
onChange(`${e.target.value},${currentEndDate}`);
}}
placeholder="Start date"
className="h-9 flex-1 bg-white"
/>
<span className="text-sm text-slate-500">and</span>
<Input
type="date"
value={(currentValue as string)?.split(",")?.[1] ?? ""}
onChange={(e) => {
const currentStartDate = (currentValue as string)?.split(",")?.[0] ?? "";
onChange(`${currentStartDate},${e.target.value}`);
}}
placeholder="End date"
className="h-9 flex-1 bg-white"
/>
</div>
);
}
// Option selector for single select validation rules
if (config.valueType === "option") {
const optionValue = typeof currentValue === "string" ? currentValue : "";
return (
<Select value={optionValue} onValueChange={onChange}>
<SelectTrigger className="h-9 min-w-[200px] bg-white">
<SelectValue placeholder="Select option" />
</SelectTrigger>
<SelectContent>
{element &&
"choices" in element &&
element.choices
.filter((choice) => choice.id !== "other" && choice.id !== "none" && "label" in choice)
.map((choice) => {
const choiceLabel =
"label" in choice
? choice.label.default || Object.values(choice.label)[0] || choice.id
: choice.id;
return (
<SelectItem key={choice.id} value={choice.id}>
{choiceLabel}
</SelectItem>
);
})}
</SelectContent>
</Select>
);
}
// File extension MultiSelect
if (ruleType === "fileExtensionIs" || ruleType === "fileExtensionIsNot") {
const extensionOptions = ALLOWED_FILE_EXTENSIONS.map((ext) => ({
value: ext,
label: `.${ext}`,
}));
const selectedExtensions = (rule.params as { extensions: string[] })?.extensions || [];
return (
<MultiSelect
options={extensionOptions}
value={selectedExtensions as TAllowedFileExtension[]}
onChange={onFileExtensionChange}
placeholder={t("environments.surveys.edit.validation.select_file_extensions")}
disabled={false}
/>
);
}
// Default text/number input
return (
<Input
type={htmlInputType}
value={currentValue ?? ""}
onChange={(e) => onChange(e.target.value)}
placeholder={config.valuePlaceholder}
className="h-9 min-w-[80px] bg-white"
min={config.valueType === "number" ? 0 : ""}
/>
);
};

View File

@@ -1,10 +1,9 @@
"use client";
import { capitalize } from "lodash";
import { PlusIcon, TrashIcon } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { v4 as uuidv7 } from "uuid";
import { v7 as uuidv7 } from "uuid";
import { TAllowedFileExtension } from "@formbricks/types/storage";
import {
TSurveyElement,
TSurveyElementTypeEnum,
@@ -17,38 +16,21 @@ import {
TValidationRule,
TValidationRuleType,
} from "@formbricks/types/surveys/validation-rules";
import { TAllowedFileExtension, ALLOWED_FILE_EXTENSIONS } from "@formbricks/types/storage";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { MultiSelect } from "@/modules/ui/components/multi-select";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { cn } from "@/modules/ui/lib/utils";
import { useGetBillingInfo } from "@/modules/utils/hooks/useGetBillingInfo";
import { RULE_TYPE_CONFIG } from "../lib/validation-rules-config";
import {
createRuleParams,
getAvailableRuleTypes,
getRuleValue,
RULES_BY_INPUT_TYPE,
} from "../lib/validation-rules-utils";
getAddressFields,
getContactInfoFields,
getDefaultRuleValue,
getRuleLabels,
parseRuleValue,
} from "../lib/validation-rules-helpers";
import { RULES_BY_INPUT_TYPE, createRuleParams, getAvailableRuleTypes } from "../lib/validation-rules-utils";
import { ValidationLogicSelector } from "./validation-logic-selector";
import { ValidationRuleRow } from "./validation-rule-row";
// Reusable input type options for OpenText elements
const INPUT_TYPE_OPTIONS = (
<>
<SelectItem value="text">{"Text"}</SelectItem>
<SelectItem value="email">{"Email"}</SelectItem>
<SelectItem value="url">{"Url"}</SelectItem>
<SelectItem value="phone">{"Phone"}</SelectItem>
<SelectItem value="number">{"Number"}</SelectItem>
</>
);
type TValidationField = TAddressField | TContactInfoField | undefined;
interface ValidationRulesEditorProps {
elementType: TSurveyElementTypeEnum;
@@ -81,28 +63,11 @@ export const ValidationRulesEditor = ({
const isContactInfo = elementType === TSurveyElementTypeEnum.ContactInfo;
const needsFieldSelector = isAddress || isContactInfo;
const addressFields: { value: TAddressField; label: string }[] = [
{ value: "addressLine1", label: t("environments.surveys.edit.address_line_1") },
{ value: "addressLine2", label: t("environments.surveys.edit.address_line_2") },
{ value: "city", label: t("environments.surveys.edit.city") },
{ value: "state", label: t("environments.surveys.edit.state") },
{ value: "zip", label: t("environments.surveys.edit.zip") },
{ value: "country", label: t("environments.surveys.edit.country") },
];
const contactInfoFields: { value: TContactInfoField; label: string }[] = [
{ value: "firstName", label: t("environments.surveys.edit.first_name") },
{ value: "lastName", label: t("environments.surveys.edit.last_name") },
{ value: "email", label: t("common.email") },
{ value: "phone", label: t("common.phone") },
{ value: "company", label: t("environments.surveys.edit.company") },
];
let fieldOptions: { value: TAddressField | TContactInfoField; label: string }[] = [];
if (isAddress) {
fieldOptions = addressFields;
fieldOptions = getAddressFields(t);
} else if (isContactInfo) {
fieldOptions = contactInfoFields;
fieldOptions = getContactInfoFields(t);
}
const {
billingInfo,
@@ -124,42 +89,18 @@ export const ValidationRulesEditor = ({
}, [billingInfo, billingInfoError, billingInfoLoading]);
// For file upload elements, use billing-based limit; for self-hosted, use 1024 MB
const effectiveMaxSizeInMB = elementType === TSurveyElementTypeEnum.FileUpload
? (isFormbricksCloud ? maxSizeInMBLimit : 1024)
: undefined;
let effectiveMaxSizeInMB: number | undefined;
if (elementType === TSurveyElementTypeEnum.FileUpload) {
if (isFormbricksCloud) {
effectiveMaxSizeInMB = maxSizeInMBLimit;
} else {
effectiveMaxSizeInMB = 1024;
}
} else {
effectiveMaxSizeInMB = undefined;
}
const ruleLabels: Record<string, string> = {
min_length: t("environments.surveys.edit.validation.min_length"),
max_length: t("environments.surveys.edit.validation.max_length"),
pattern: t("environments.surveys.edit.validation.pattern"),
email: t("environments.surveys.edit.validation.email"),
url: t("environments.surveys.edit.validation.url"),
phone: t("environments.surveys.edit.validation.phone"),
min_value: t("environments.surveys.edit.validation.min_value"),
max_value: t("environments.surveys.edit.validation.max_value"),
min_selections: t("environments.surveys.edit.validation.min_selections"),
max_selections: t("environments.surveys.edit.validation.max_selections"),
characters: t("environments.surveys.edit.validation.characters"),
options_selected: t("environments.surveys.edit.validation.options_selected"),
is: t("environments.surveys.edit.validation.is"),
is_not: t("environments.surveys.edit.validation.is_not"),
contains: t("environments.surveys.edit.validation.contains"),
does_not_contain: t("environments.surveys.edit.validation.does_not_contain"),
is_greater_than: t("environments.surveys.edit.validation.is_greater_than"),
is_less_than: t("environments.surveys.edit.validation.is_less_than"),
is_later_than: t("environments.surveys.edit.validation.is_later_than"),
is_earlier_than: t("environments.surveys.edit.validation.is_earlier_than"),
is_between: t("environments.surveys.edit.validation.is_between"),
is_not_between: t("environments.surveys.edit.validation.is_not_between"),
minimum_options_ranked: t("environments.surveys.edit.validation.minimum_options_ranked"),
minimum_rows_answered: t("environments.surveys.edit.validation.minimum_rows_answered"),
file_size_at_least: t("environments.surveys.edit.validation.file_size_at_least"),
file_size_at_most: t("environments.surveys.edit.validation.file_size_at_most"),
file_extension_is: t("environments.surveys.edit.validation.file_extension_is"),
file_extension_is_not: t("environments.surveys.edit.validation.file_extension_is_not"),
kb: t("environments.surveys.edit.validation.kb"),
mb: t("environments.surveys.edit.validation.mb"),
};
const ruleLabels = getRuleLabels(t);
const isEnabled = validationRules.length > 0;
@@ -179,26 +120,7 @@ export const ValidationRulesEditor = ({
if (availableRules.length > 0) {
const defaultRuleType = availableRules[0];
const config = RULE_TYPE_CONFIG[defaultRuleType];
let defaultValue: number | string | undefined = undefined;
if (config.needsValue && config.valueType === "text") {
defaultValue = "";
} else if (config.needsValue && config.valueType === "option") {
// For option type, get first available choice ID
if (element && "choices" in element) {
const firstChoice = element.choices.find((c) => c.id !== "other" && c.id !== "none");
defaultValue = firstChoice?.id ?? "";
} else {
defaultValue = "";
}
} else if (config.needsValue && config.valueType === "ranking") {
// For ranking type, get first available choice ID and default position 1
if (element && "choices" in element) {
const firstChoice = element.choices.find((c) => c.id !== "other" && c.id !== "none");
defaultValue = firstChoice ? `${firstChoice.id},1` : ",1";
} else {
defaultValue = ",1";
}
}
const defaultValue = getDefaultRuleValue(config, element);
const newRule: TValidationRule = {
id: uuidv7(),
type: defaultRuleType,
@@ -217,7 +139,13 @@ export const ValidationRulesEditor = ({
const handleAddRule = (insertAfterIndex: number) => {
// For address/contact info, get rules for the field of the rule we're inserting after (or first field)
const insertAfterRule = validationRules[insertAfterIndex];
const fieldForNewRule = insertAfterRule?.field ?? (needsFieldSelector && fieldOptions.length > 0 ? fieldOptions[0].value : undefined);
let fieldForNewRule: TValidationField;
if (insertAfterRule?.field) {
fieldForNewRule = insertAfterRule.field;
} else if (needsFieldSelector && fieldOptions.length > 0) {
fieldForNewRule = fieldOptions[0].value;
}
const availableRules = getAvailableRuleTypes(
elementType,
validationRules,
@@ -228,32 +156,18 @@ export const ValidationRulesEditor = ({
const newRuleType = availableRules[0];
const config = RULE_TYPE_CONFIG[newRuleType];
let defaultValue: number | string | undefined = undefined;
if (config.needsValue && config.valueType === "text") {
defaultValue = "";
} else if (config.needsValue && config.valueType === "option") {
// For option type, get first available choice ID
if (element && "choices" in element) {
const firstChoice = element.choices.find((c) => c.id !== "other" && c.id !== "none");
defaultValue = firstChoice?.id ?? "";
} else {
defaultValue = "";
}
} else if (config.needsValue && config.valueType === "ranking") {
// For ranking type, get first available choice ID and default position 1
if (element && "choices" in element) {
const firstChoice = element.choices.find((c) => c.id !== "other" && c.id !== "none");
defaultValue = firstChoice ? `${firstChoice.id},1` : ",1";
} else {
defaultValue = ",1";
}
const defaultValue = getDefaultRuleValue(config, element);
let defaultField: TValidationField;
if (needsFieldSelector && fieldOptions.length > 0) {
defaultField = fieldOptions[0].value;
}
const newRule: TValidationRule = {
id: uuidv7(),
type: newRuleType,
params: createRuleParams(newRuleType, defaultValue),
// For address/contact info, set field to first available field if not set
field: needsFieldSelector && fieldOptions.length > 0 ? fieldOptions[0].value : undefined,
field: defaultField,
} as TValidationRule;
const newRules = [...validationRules];
newRules.splice(insertAfterIndex + 1, 0, newRule);
@@ -295,7 +209,7 @@ export const ValidationRulesEditor = ({
onUpdateValidation({ rules: updated, logic: validationLogic });
};
const handleFieldChange = (ruleId: string, field: TAddressField | TContactInfoField | undefined) => {
const handleFieldChange = (ruleId: string, field: TValidationField) => {
const ruleToUpdate = validationRules.find((r) => r.id === ruleId);
if (!ruleToUpdate) return;
@@ -333,27 +247,7 @@ export const ValidationRulesEditor = ({
if (rule.id !== ruleId) return rule;
const ruleType = rule.type;
const config = RULE_TYPE_CONFIG[ruleType];
let parsedValue: string | number = value;
// Handle file extension formatting: auto-add dot if missing
if (ruleType === "fileExtensionIs" || ruleType === "fileExtensionIsNot") {
// Normalize extension: ensure it starts with a dot
parsedValue = value.startsWith(".") ? value : `.${value}`;
} else if (config.valueType === "number") {
parsedValue = Number(value) || 0;
// For fileSizeAtMost, ensure it doesn't exceed billing-based limit
if (ruleType === "fileSizeAtMost" && effectiveMaxSizeInMB !== undefined) {
const currentParams = rule.params as { size: number; unit: "KB" | "MB" };
const unit = currentParams?.unit || "MB";
const sizeInMB = unit === "KB" ? parsedValue / 1024 : parsedValue;
// Cap the value at effectiveMaxSizeInMB
if (sizeInMB > effectiveMaxSizeInMB) {
parsedValue = unit === "KB" ? effectiveMaxSizeInMB * 1024 : effectiveMaxSizeInMB;
}
}
}
const parsedValue = parseRuleValue(ruleType, value, config, rule.params, effectiveMaxSizeInMB);
return {
...rule,
@@ -363,6 +257,19 @@ export const ValidationRulesEditor = ({
onUpdateValidation({ rules: updated, logic: validationLogic });
};
const handleFileExtensionChange = (ruleId: string, extensions: TAllowedFileExtension[]) => {
const updated = validationRules.map((r) => {
if (r.id !== ruleId) return r;
return {
...r,
params: {
extensions,
},
} as TValidationRule;
});
onUpdateValidation({ rules: updated, logic: validationLogic });
};
const handleFileSizeUnitChange = (ruleId: string, unit: "KB" | "MB") => {
const updated = validationRules.map((rule) => {
if (rule.id !== ruleId) return rule;
@@ -441,16 +348,21 @@ export const ValidationRulesEditor = ({
}
};
// For address/contact info, use first field if no rules exist, or use the field from last rule
let defaultField: TValidationField;
if (needsFieldSelector && validationRules.length > 0) {
defaultField = validationRules.at(-1)?.field;
} else if (needsFieldSelector && fieldOptions.length > 0) {
defaultField = fieldOptions[0].value;
} else {
defaultField = undefined;
}
const availableRulesForAdd = getAvailableRuleTypes(
elementType,
validationRules,
elementType === TSurveyElementTypeEnum.OpenText ? inputType : undefined,
// For address/contact info, use first field if no rules exist, or use the field from last rule
needsFieldSelector && validationRules.length > 0
? validationRules[validationRules.length - 1]?.field
: needsFieldSelector && fieldOptions.length > 0
? fieldOptions[0].value
: undefined
defaultField
);
const canAddMore = availableRulesForAdd.length > 0;
@@ -470,271 +382,36 @@ export const ValidationRulesEditor = ({
childrenContainerClass="flex-col p-3 gap-2">
{/* Validation Logic Selector - only show when there are 2+ rules */}
{validationRules.length >= 2 && (
<div className="flex w-full items-center gap-2">
<Select
value={validationLogic}
onValueChange={(value) => onUpdateValidation({ rules: validationRules, logic: value as TValidationLogic })}>
<SelectTrigger className="h-8 w-fit bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="and">{t("environments.surveys.edit.validation_logic_and")}</SelectItem>
<SelectItem value="or">{t("environments.surveys.edit.validation_logic_or")}</SelectItem>
</SelectContent>
</Select>
</div>
<ValidationLogicSelector
value={validationLogic}
onChange={(value) => onUpdateValidation({ rules: validationRules, logic: value })}
/>
)}
<div className="flex w-full flex-col gap-2">
{validationRules.map((rule, index) => {
const ruleType = rule.type;
const config = RULE_TYPE_CONFIG[ruleType];
const currentValue = getRuleValue(rule);
// Get available types for this rule (current type + unused types, no duplicates)
// For address/contact info, filter by selected field
const ruleField = rule.field;
const otherAvailableTypes = getAvailableRuleTypes(
elementType,
validationRules.filter((r) => r.id !== rule.id),
elementType === TSurveyElementTypeEnum.OpenText ? inputType : undefined,
ruleField
).filter((t) => t !== ruleType);
const availableTypesForSelect = [ruleType, ...otherAvailableTypes];
// Determine HTML input type for value inputs (not the validation input type)
let htmlInputType: "number" | "date" | "text" = "text";
if (config.valueType === "number") {
htmlInputType = "number";
} else if (
ruleType.startsWith("is") &&
(ruleType.includes("Later") || ruleType.includes("Earlier") || ruleType.includes("On"))
) {
htmlInputType = "date";
}
// Check if this is OpenText and first rule - show input type selector
const isOpenText = elementType === TSurveyElementTypeEnum.OpenText;
const isFirstRule = index === 0;
const showInputTypeSelector = isOpenText && isFirstRule;
return (
<div key={rule.id} className="flex w-full items-center gap-2">
{/* Field Selector (for Address and Contact Info elements) */}
{needsFieldSelector && (
<Select
value={rule.field ?? ""}
onValueChange={(value) =>
handleFieldChange(rule.id, value ? (value as TAddressField | TContactInfoField) : undefined)
}>
<SelectTrigger className="h-9 min-w-[140px] bg-white">
<SelectValue placeholder={t("environments.surveys.edit.select_field")} />
</SelectTrigger>
<SelectContent>
{fieldOptions.map((field) => (
<SelectItem key={field.value} value={field.value}>
{field.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* Input Type Selector (only for OpenText, first rule) */}
{showInputTypeSelector && inputType !== undefined && onUpdateInputType && (
<Select
value={inputType}
onValueChange={(value) =>
handleInputTypeChange(value as TSurveyOpenTextElementInputType)
}>
<SelectTrigger className="h-9 min-w-[120px] bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>{INPUT_TYPE_OPTIONS}</SelectContent>
</Select>
)}
{/* Input Type Display (disabled, for subsequent rules) */}
{isOpenText && !isFirstRule && inputType !== undefined && (
<Select disabled value={inputType}>
<SelectTrigger className="h-9 min-w-[120px] bg-slate-100 cursor-not-allowed">
<SelectValue />
</SelectTrigger>
<SelectContent>{INPUT_TYPE_OPTIONS}</SelectContent>
</Select>
)}
{/* Rule Type Selector */}
<Select
value={ruleType}
onValueChange={(value) => handleRuleTypeChange(rule.id, value as TValidationRuleType)}>
<SelectTrigger className={cn("bg-white", config.needsValue ? "min-w-[200px]" : "flex-1")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableTypesForSelect.map((type) => (
<SelectItem key={type} value={type}>
{capitalize(ruleLabels[RULE_TYPE_CONFIG[type].labelKey])}
</SelectItem>
))}
</SelectContent>
</Select>
{/* Value Input (if needed) */}
{config.needsValue && (
<div className="flex w-full items-center gap-2">
{ruleType === "isBetween" || ruleType === "isNotBetween" ? (
// Special handling for date range inputs
<div className="flex w-full items-center gap-2">
<Input
type="date"
value={(currentValue as string)?.split(",")?.[0] ?? ""}
onChange={(e) => {
const currentEndDate = (currentValue as string)?.split(",")?.[1] ?? "";
handleRuleValueChange(rule.id, `${e.target.value},${currentEndDate}`);
}}
placeholder="Start date"
className="h-9 flex-1 bg-white"
/>
<span className="text-sm text-slate-500">and</span>
<Input
type="date"
value={(currentValue as string)?.split(",")?.[1] ?? ""}
onChange={(e) => {
const currentStartDate = (currentValue as string)?.split(",")?.[0] ?? "";
handleRuleValueChange(rule.id, `${currentStartDate},${e.target.value}`);
}}
placeholder="End date"
className="h-9 flex-1 bg-white"
/>
</div>
) : (
(() => {
if (config.valueType === "option") {
// Option selector for single select validation rules
const optionValue = typeof currentValue === "string" ? currentValue : "";
return (
<Select
value={optionValue}
onValueChange={(value) => handleRuleValueChange(rule.id, value)}>
<SelectTrigger className="h-9 min-w-[200px] bg-white">
<SelectValue placeholder="Select option" />
</SelectTrigger>
<SelectContent>
{element &&
"choices" in element &&
element.choices
.filter(
(choice) =>
choice.id !== "other" && choice.id !== "none" && "label" in choice
)
.map((choice) => {
const choiceLabel =
"label" in choice
? choice.label.default || Object.values(choice.label)[0] || choice.id
: choice.id;
return (
<SelectItem key={choice.id} value={choice.id}>
{choiceLabel}
</SelectItem>
);
})}
</SelectContent>
</Select>
);
}
// File extension MultiSelect
if (ruleType === "fileExtensionIs" || ruleType === "fileExtensionIsNot") {
const extensionOptions = ALLOWED_FILE_EXTENSIONS.map((ext) => ({
value: ext,
label: `.${ext}`,
}));
const selectedExtensions =
(rule.params as { extensions: string[] })?.extensions || [];
return (
<MultiSelect
options={extensionOptions}
value={selectedExtensions as TAllowedFileExtension[]}
onChange={(selected) => {
const updated = validationRules.map((r) => {
if (r.id !== rule.id) return r;
return {
...r,
params: {
extensions: selected,
},
} as TValidationRule;
});
onUpdateValidation({ rules: updated, logic: validationLogic });
}}
placeholder={t("environments.surveys.edit.validation.select_file_extensions")}
disabled={false}
/>
);
}
return (
<Input
type={htmlInputType}
value={currentValue ?? ""}
onChange={(e) => handleRuleValueChange(rule.id, e.target.value)}
placeholder={config.valuePlaceholder}
className="h-9 min-w-[80px] bg-white"
min={config.valueType === "number" ? 0 : ""}
/>
);
})()
)}
{/* Unit selector (if applicable) */}
{config.unitOptions && config.unitOptions.length > 0 && (
<Select
value={
ruleType === "fileSizeAtLeast" || ruleType === "fileSizeAtMost"
? (rule.params as { size: number; unit: "KB" | "MB" }).unit
: config.unitOptions[0].value
}
onValueChange={
ruleType === "fileSizeAtLeast" || ruleType === "fileSizeAtMost"
? (value) => handleFileSizeUnitChange(rule.id, value as "KB" | "MB")
: undefined
}>
<SelectTrigger className="flex-1 bg-white" disabled={config.unitOptions.length === 1}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{config.unitOptions.map((unit) => (
<SelectItem key={unit.value} value={unit.value}>
{ruleLabels[unit.labelKey]}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
{/* Delete button */}
<Button
variant="outline"
size="icon"
type="button"
onClick={() => handleDeleteRule(rule.id)}
className="shrink-0 bg-white">
<TrashIcon className="h-4 w-4" />
</Button>
{/* Add button */}
{canAddMore && (
<Button
variant="outline"
size="icon"
type="button"
onClick={() => handleAddRule(index)}
className="shrink-0 bg-white">
<PlusIcon className="h-4 w-4" />
</Button>
)}
</div>
);
})}
{validationRules.map((rule, index) => (
<ValidationRuleRow
key={rule.id}
rule={rule}
index={index}
elementType={elementType}
element={element}
inputType={inputType}
onInputTypeChange={handleInputTypeChange}
fieldOptions={fieldOptions}
needsFieldSelector={needsFieldSelector}
validationRules={validationRules}
effectiveMaxSizeInMB={effectiveMaxSizeInMB}
ruleLabels={ruleLabels}
onFieldChange={handleFieldChange}
onRuleTypeChange={handleRuleTypeChange}
onRuleValueChange={handleRuleValueChange}
onFileExtensionChange={handleFileExtensionChange}
onFileSizeUnitChange={handleFileSizeUnitChange}
onDelete={handleDeleteRule}
onAdd={handleAddRule}
canAddMore={canAddMore}
/>
))}
</div>
</AdvancedOptionToggle>
);

View File

@@ -0,0 +1,154 @@
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import {
TAddressField,
TContactInfoField,
TValidationRule,
TValidationRuleType,
} from "@formbricks/types/surveys/validation-rules";
import { RULE_TYPE_CONFIG } from "./validation-rules-config";
// Field options for address elements
export const getAddressFields = (t: (key: string) => string): { value: TAddressField; label: string }[] => [
{ value: "addressLine1", label: t("environments.surveys.edit.address_line_1") },
{ value: "addressLine2", label: t("environments.surveys.edit.address_line_2") },
{ value: "city", label: t("environments.surveys.edit.city") },
{ value: "state", label: t("environments.surveys.edit.state") },
{ value: "zip", label: t("environments.surveys.edit.zip") },
{ value: "country", label: t("environments.surveys.edit.country") },
];
// Field options for contact info elements
export const getContactInfoFields = (
t: (key: string) => string
): { value: TContactInfoField; label: string }[] => [
{ value: "firstName", label: t("environments.surveys.edit.first_name") },
{ value: "lastName", label: t("environments.surveys.edit.last_name") },
{ value: "email", label: t("common.email") },
{ value: "phone", label: t("common.phone") },
{ value: "company", label: t("environments.surveys.edit.company") },
];
// Rule labels mapping
export const getRuleLabels = (t: (key: string) => string): Record<string, string> => ({
min_length: t("environments.surveys.edit.validation.min_length"),
max_length: t("environments.surveys.edit.validation.max_length"),
pattern: t("environments.surveys.edit.validation.pattern"),
email: t("environments.surveys.edit.validation.email"),
url: t("environments.surveys.edit.validation.url"),
phone: t("environments.surveys.edit.validation.phone"),
min_value: t("environments.surveys.edit.validation.min_value"),
max_value: t("environments.surveys.edit.validation.max_value"),
min_selections: t("environments.surveys.edit.validation.min_selections"),
max_selections: t("environments.surveys.edit.validation.max_selections"),
characters: t("environments.surveys.edit.validation.characters"),
options_selected: t("environments.surveys.edit.validation.options_selected"),
is: t("environments.surveys.edit.validation.is"),
is_not: t("environments.surveys.edit.validation.is_not"),
contains: t("environments.surveys.edit.validation.contains"),
does_not_contain: t("environments.surveys.edit.validation.does_not_contain"),
is_greater_than: t("environments.surveys.edit.validation.is_greater_than"),
is_less_than: t("environments.surveys.edit.validation.is_less_than"),
is_later_than: t("environments.surveys.edit.validation.is_later_than"),
is_earlier_than: t("environments.surveys.edit.validation.is_earlier_than"),
is_between: t("environments.surveys.edit.validation.is_between"),
is_not_between: t("environments.surveys.edit.validation.is_not_between"),
minimum_options_ranked: t("environments.surveys.edit.validation.minimum_options_ranked"),
minimum_rows_answered: t("environments.surveys.edit.validation.minimum_rows_answered"),
file_size_at_least: t("environments.surveys.edit.validation.file_size_at_least"),
file_size_at_most: t("environments.surveys.edit.validation.file_size_at_most"),
file_extension_is: t("environments.surveys.edit.validation.file_extension_is"),
file_extension_is_not: t("environments.surveys.edit.validation.file_extension_is_not"),
kb: t("environments.surveys.edit.validation.kb"),
mb: t("environments.surveys.edit.validation.mb"),
});
// Helper function to get default value for a validation rule based on its config and element
export const getDefaultRuleValue = (
config: (typeof RULE_TYPE_CONFIG)[TValidationRuleType],
element?: TSurveyElement
): number | string | undefined => {
if (!config.needsValue) {
return undefined;
}
if (config.valueType === "text") {
return "";
}
if (config.valueType === "option") {
if (element && "choices" in element) {
const firstChoice = element.choices.find((c) => c.id !== "other" && c.id !== "none");
return firstChoice?.id ?? "";
}
return "";
}
if (config.valueType === "ranking") {
if (element && "choices" in element) {
const firstChoice = element.choices.find((c) => c.id !== "other" && c.id !== "none");
return firstChoice ? `${firstChoice.id},1` : ",1";
}
return ",1";
}
return undefined;
};
// Helper function to normalize file extension format
export const normalizeFileExtension = (value: string): string => {
return value.startsWith(".") ? value : `.${value}`;
};
// Helper function to validate and cap file size based on billing limits
export const capFileSizeValue = (
parsedValue: number,
currentParams: TValidationRule["params"],
effectiveMaxSizeInMB: number
): number => {
const unit = (currentParams as { size: number; unit: "KB" | "MB" })?.unit || "MB";
const sizeInMB = unit === "KB" ? parsedValue / 1024 : parsedValue;
// Cap the value at effectiveMaxSizeInMB
if (sizeInMB > effectiveMaxSizeInMB) {
return unit === "KB" ? effectiveMaxSizeInMB * 1024 : effectiveMaxSizeInMB;
}
return parsedValue;
};
// Helper function to parse number values with file size validation
export const parseNumberValue = (
value: string,
ruleType: TValidationRuleType,
currentParams: TValidationRule["params"],
effectiveMaxSizeInMB?: number
): number => {
const parsedValue = Number(value) || 0;
// For fileSizeAtMost, ensure it doesn't exceed billing-based limit
if (ruleType === "fileSizeAtMost" && effectiveMaxSizeInMB !== undefined) {
return capFileSizeValue(parsedValue, currentParams, effectiveMaxSizeInMB);
}
return parsedValue;
};
// Helper function to parse and validate rule value based on rule type
export const parseRuleValue = (
ruleType: TValidationRuleType,
value: string,
config: (typeof RULE_TYPE_CONFIG)[TValidationRuleType],
currentParams: TValidationRule["params"],
effectiveMaxSizeInMB?: number
): string | number => {
// Handle file extension formatting: auto-add dot if missing
if (ruleType === "fileExtensionIs" || ruleType === "fileExtensionIsNot") {
return normalizeFileExtension(value);
}
if (config.valueType === "number") {
return parseNumberValue(value, ruleType, currentParams, effectiveMaxSizeInMB);
}
return value;
};