This commit is contained in:
Dhruwang
2026-01-12 12:48:02 +05:30
parent 1f59ef3d57
commit 08773a3784
10 changed files with 63 additions and 710 deletions

View File

@@ -1601,8 +1601,6 @@
"is_not": "is not",
"is_not_between": "is not between",
"is_not_selected": "is not selected",
"is_on_or_earlier_than": "is on or earlier than",
"is_on_or_later_than": "is on or later than",
"is_selected": "is selected",
"is_shorter_than": "is shorter than",
"max_length": "At most (characters)",
@@ -1611,12 +1609,11 @@
"min_length": "At least (characters)",
"min_selections": "At least",
"min_value": "At least (value)",
"minimum_rows_answered": "Minimum rows answered",
"options_selected": "options selected",
"pattern": "Matches regex pattern",
"phone": "Is valid phone",
"position_is": "Position is",
"position_is_higher_than": "Position is higher than",
"position_is_lower_than": "Position is lower than",
"minimum_options_ranked": "Minimum options ranked",
"url": "Is valid URL",
"file_size_at_least": "File size is at least",
"file_size_at_most": "File size is at most",

View File

@@ -12,10 +12,7 @@ import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TI18nString } from "@formbricks/types/i18n";
import { TSurveyElementTypeEnum, TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
import { TShuffleOption, TSurvey } from "@formbricks/types/surveys/types";
import {
TValidationRulesForMultipleChoiceMulti,
TValidationRulesForMultipleChoiceSingle,
} from "@formbricks/types/surveys/validation-rules";
import { TValidationRulesForMultipleChoiceMulti } from "@formbricks/types/surveys/validation-rules";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
@@ -404,7 +401,7 @@ export const MultipleChoiceElementForm = ({
locale={locale}
/>
{element.type === TSurveyElementTypeEnum.MultipleChoiceMulti ? (
{element.type === TSurveyElementTypeEnum.MultipleChoiceMulti && (
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.MultipleChoiceMulti}
validationRules={element.validationRules ?? []}
@@ -421,23 +418,6 @@ export const MultipleChoiceElementForm = ({
});
}}
/>
) : (
<ValidationRulesEditor
elementType={TSurveyElementTypeEnum.MultipleChoiceSingle}
validationRules={element.validationRules ?? []}
onUpdateRules={(rules: TValidationRulesForMultipleChoiceSingle) => {
updateElement(elementIdx, {
validationRules: rules,
});
}}
element={element}
validationLogic={element.validationLogic}
onUpdateValidationLogic={(logic) => {
updateElement(elementIdx, {
validationLogic: logic,
});
}}
/>
)}
</form>
);

View File

@@ -91,19 +91,12 @@ export const ValidationRulesEditor = ({
is_shorter_than: t("environments.surveys.edit.validation.is_shorter_than"),
is_greater_than: t("environments.surveys.edit.validation.is_greater_than"),
is_less_than: t("environments.surveys.edit.validation.is_less_than"),
is_on_or_later_than: t("environments.surveys.edit.validation.is_on_or_later_than"),
is_later_than: t("environments.surveys.edit.validation.is_later_than"),
is_on_or_earlier_than: t("environments.surveys.edit.validation.is_on_or_earlier_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"),
is_selected: t("environments.surveys.edit.validation.is_selected"),
is_not_selected: t("environments.surveys.edit.validation.is_not_selected"),
position_is: t("environments.surveys.edit.validation.position_is"),
position_is_higher_than: t("environments.surveys.edit.validation.position_is_higher_than"),
position_is_lower_than: t("environments.surveys.edit.validation.position_is_lower_than"),
answers_provided_greater_than: t("environments.surveys.edit.validation.answers_provided_greater_than"),
answers_provided_smaller_than: t("environments.surveys.edit.validation.answers_provided_smaller_than"),
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"),
@@ -312,15 +305,6 @@ export const ValidationRulesEditor = ({
const config = RULE_TYPE_CONFIG[ruleType];
const currentValue = getRuleValue(rule);
// For ranking rules, extract optionId and position from params
const rankingParams =
ruleType === "positionIs" ||
ruleType === "positionIsHigherThan" ||
ruleType === "positionIsLowerThan"
? rule.params
: null;
const rankingOptionId = rankingParams?.optionId ?? "";
const rankingPosition = rankingParams?.position ?? 1;
// Get available types for this rule (current type + unused types, no duplicates)
const otherAvailableTypes = getAvailableRuleTypes(
@@ -421,56 +405,6 @@ export const ValidationRulesEditor = ({
</Select>
);
}
if (config.valueType === "ranking") {
// Ranking rules: option selector + position input
return (
<div className="flex w-full items-center gap-2">
<Select
value={rankingOptionId}
onValueChange={(optionId) => {
handleRuleValueChange(rule.id, `${optionId},${rankingPosition}`);
}}>
<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>
<span className="text-sm text-slate-500">position</span>
<Input
type="number"
value={rankingPosition}
onChange={(e) => {
const newPosition = Number(e.target.value) || 1;
handleRuleValueChange(rule.id, `${rankingOptionId},${newPosition}`);
}}
placeholder="1"
className="h-9 w-20 bg-white"
min={1}
/>
</div>
);
}
// File extension MultiSelect
if (ruleType === "fileExtensionIs" || ruleType === "fileExtensionIsNot") {

View File

@@ -119,24 +119,12 @@ export const RULE_TYPE_CONFIG: Record<
valueType: "number",
valuePlaceholder: "100",
},
isOnOrLaterThan: {
labelKey: "is_on_or_later_than",
needsValue: true,
valueType: "text",
valuePlaceholder: "YYYY-MM-DD",
},
isLaterThan: {
labelKey: "is_later_than",
needsValue: true,
valueType: "text",
valuePlaceholder: "YYYY-MM-DD",
},
isOnOrEarlierThan: {
labelKey: "is_on_or_earlier_than",
needsValue: true,
valueType: "text",
valuePlaceholder: "YYYY-MM-DD",
},
isEarlierThan: {
labelKey: "is_earlier_than",
needsValue: true,
@@ -155,42 +143,17 @@ export const RULE_TYPE_CONFIG: Record<
valueType: "text",
valuePlaceholder: "YYYY-MM-DD,YYYY-MM-DD",
},
isSelected: {
labelKey: "is_selected",
needsValue: true,
valueType: "option",
},
isNotSelected: {
labelKey: "is_not_selected",
needsValue: true,
valueType: "option",
},
positionIs: {
labelKey: "position_is",
needsValue: true,
valueType: "ranking",
},
positionIsHigherThan: {
labelKey: "position_is_higher_than",
needsValue: true,
valueType: "ranking",
},
positionIsLowerThan: {
labelKey: "position_is_lower_than",
needsValue: true,
valueType: "ranking",
},
answersProvidedGreaterThan: {
labelKey: "answers_provided_greater_than",
minRanked: {
labelKey: "minimum_options_ranked",
needsValue: true,
valueType: "number",
valuePlaceholder: "1",
},
answersProvidedSmallerThan: {
labelKey: "answers_provided_smaller_than",
minRowsAnswered: {
labelKey: "minimum_rows_answered",
needsValue: true,
valueType: "number",
valuePlaceholder: "5",
valuePlaceholder: "1",
},
fileSizeAtLeast: {
labelKey: "file_size_at_least",

View File

@@ -44,36 +44,16 @@ describe("getAvailableRuleTypes", () => {
expect(available).toContain("pattern");
});
test("should return isSelected and isNotSelected for multipleChoiceSingle element", () => {
test("should return empty array for multipleChoiceSingle element (no validation rules)", () => {
const elementType = TSurveyElementTypeEnum.MultipleChoiceSingle;
const existingRules: TValidationRule[] = [];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual(["isSelected", "isNotSelected"]);
});
test("should return empty array for multipleChoiceSingle when all rules are already added", () => {
const elementType = TSurveyElementTypeEnum.MultipleChoiceSingle;
const existingRules: TValidationRule[] = [
{
id: "rule1",
type: "isSelected",
params: { optionId: "option1" },
},
{
id: "rule2",
type: "isNotSelected",
params: { optionId: "option2" },
},
];
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toEqual([]);
});
test("should return minSelections, maxSelections, isSelected, isNotSelected for multipleChoiceMulti element", () => {
test("should return minSelections, maxSelections for multipleChoiceMulti element", () => {
const elementType = TSurveyElementTypeEnum.MultipleChoiceMulti;
const existingRules: TValidationRule[] = [];
@@ -81,9 +61,7 @@ describe("getAvailableRuleTypes", () => {
expect(available).toContain("minSelections");
expect(available).toContain("maxSelections");
expect(available).toContain("isSelected");
expect(available).toContain("isNotSelected");
expect(available.length).toBe(4);
expect(available.length).toBe(2);
});
test("should return empty array for rating element (no validation rules)", () => {
@@ -110,9 +88,7 @@ describe("getAvailableRuleTypes", () => {
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toContain("isOnOrLaterThan");
expect(available).toContain("isLaterThan");
expect(available).toContain("isOnOrEarlierThan");
expect(available).toContain("isEarlierThan");
expect(available).toContain("isBetween");
expect(available).toContain("isNotBetween");
@@ -133,9 +109,8 @@ describe("getAvailableRuleTypes", () => {
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toContain("answersProvidedGreaterThan");
expect(available).toContain("answersProvidedSmallerThan");
expect(available.length).toBe(2);
expect(available).toContain("minRowsAnswered");
expect(available.length).toBe(1);
});
test("should return ranking validation rules for ranking element", () => {
@@ -144,10 +119,8 @@ describe("getAvailableRuleTypes", () => {
const available = getAvailableRuleTypes(elementType, existingRules);
expect(available).toContain("positionIs");
expect(available).toContain("positionIsHigherThan");
expect(available).toContain("positionIsLowerThan");
expect(available.length).toBe(3);
expect(available).toContain("minRanked");
expect(available.length).toBe(1);
});
test("should return empty array for fileUpload element (no validation rules)", () => {

View File

@@ -44,25 +44,6 @@ export const getRuleValue = (rule: TValidationRule): number | string | undefined
if ("startDate" in params && "endDate" in params) {
return `${params.startDate},${params.endDate}`;
}
// Check for ranking rules first (they have both optionId and position)
if (
rule.type === "positionIs" ||
rule.type === "positionIsHigherThan" ||
rule.type === "positionIsLowerThan"
) {
// After checking rule.type, TypeScript narrows rule.params to ranking rule params
if ("position" in rule.params) {
const positionValue = rule.params.position;
if (typeof positionValue === "number") {
return positionValue;
}
}
return undefined;
}
if ("optionId" in params) {
// For single/multi select rules, return optionId
return params.optionId;
}
// File upload rules
if ("size" in params && "unit" in params) {
// For file size rules, return size as number (unit is stored separately)
@@ -116,12 +97,8 @@ export const createRuleParams = (
return { min: Number(value) || 0 };
case "isLessThan":
return { max: Number(value) || 100 };
case "isOnOrLaterThan":
return { date: value === undefined || value === null ? "" : String(value) };
case "isLaterThan":
return { date: value === undefined || value === null ? "" : String(value) };
case "isOnOrEarlierThan":
return { date: value === undefined || value === null ? "" : String(value) };
case "isEarlierThan":
return { date: value === undefined || value === null ? "" : String(value) };
case "isBetween":
@@ -139,29 +116,10 @@ export const createRuleParams = (
return { min: Number(value) || 1 };
case "maxSelections":
return { max: Number(value) || 3 };
case "isSelected":
case "isNotSelected":
return { optionId: value === undefined || value === null ? "" : String(value) };
case "positionIs":
case "positionIsHigherThan":
case "positionIsLowerThan":
// For ranking rules, value is a comma-separated string: "optionId,position"
if (typeof value === "string" && value.includes(",")) {
const [optionId, position] = value.split(",");
return {
optionId: optionId?.trim() || "",
position: Number(position?.trim()) || 1,
};
}
// Fallback: assume value is just the position, optionId will be set separately
return {
optionId: "",
position: Number(value) || 1,
};
case "answersProvidedGreaterThan":
case "minRanked":
return { min: Number(value) || 1 };
case "minRowsAnswered":
return { min: Number(value) || 1 };
case "answersProvidedSmallerThan":
return { max: Number(value) || 5 };
case "fileSizeAtLeast":
// Value should be number, unit is handled separately in the UI
return { size: Number(value) || 1, unit: "MB" as const };

View File

@@ -54,8 +54,6 @@
"is_less_than": "Please enter a value less than {max}",
"is_longer_than": "Please enter more than {min} characters",
"is_not_between": "Please select a date not between {startDate} and {endDate}",
"is_on_or_earlier_than": "Please select a date on or earlier than {date}",
"is_on_or_later_than": "Please select a date on or later than {date}",
"is_shorter_than": "Please enter less than {max} characters",
"max_length": "Please enter no more than {max} characters",
"max_selections": "Please select no more than {max} options",
@@ -63,15 +61,14 @@
"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}",
"minimum_rows_answered": "Please answer at least {min} rows",
"option_must_be_selected": "Please select {option}",
"option_must_not_be_selected": "Please do not select {option}",
"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_url": "Please enter a valid URL",
"please_fill_out_this_field": "Please fill out this field",
"position_must_be": "{option} must be at position {position}",
"position_must_be_higher_than": "{option} must be ranked higher than position {position}",
"position_must_be_lower_than": "{option} must be ranked lower than position {position}",
"minimum_options_ranked": "Please rank at least {min} options",
"recaptcha_error": {
"message": "Your response could not be submitted because it was flagged as automated activity. If you breathe, please try again.",
"title": "We couldn't verify that you're human."

View File

@@ -4,8 +4,6 @@ import type { TResponseDataValue } from "@formbricks/types/responses";
import type { TSurveyElement } from "@formbricks/types/surveys/elements";
import type {
TValidationRuleParams,
TValidationRuleParamsAnswersProvidedGreaterThan,
TValidationRuleParamsAnswersProvidedSmallerThan,
TValidationRuleParamsContains,
TValidationRuleParamsDoesNotContain,
TValidationRuleParamsDoesNotEqual,
@@ -22,22 +20,17 @@ import type {
TValidationRuleParamsIsLessThan,
TValidationRuleParamsIsLongerThan,
TValidationRuleParamsIsNotBetween,
TValidationRuleParamsIsNotSelected,
TValidationRuleParamsIsOnOrEarlierThan,
TValidationRuleParamsIsOnOrLaterThan,
TValidationRuleParamsIsSelected,
TValidationRuleParamsIsShorterThan,
TValidationRuleParamsMaxLength,
TValidationRuleParamsMaxSelections,
TValidationRuleParamsMaxValue,
TValidationRuleParamsMinLength,
TValidationRuleParamsMinRanked,
TValidationRuleParamsMinRowsAnswered,
TValidationRuleParamsMinSelections,
TValidationRuleParamsMinValue,
TValidationRuleParamsPattern,
TValidationRuleParamsPhone,
TValidationRuleParamsPositionIs,
TValidationRuleParamsPositionIsHigherThan,
TValidationRuleParamsPositionIsLowerThan,
TValidationRuleParamsUrl,
TValidationRuleType,
} from "@formbricks/types/surveys/validation-rules";
@@ -398,21 +391,6 @@ export const validators: Record<TValidationRuleType, TValidator> = {
return t("errors.is_less_than", { max: typedParams.max });
},
},
isOnOrLaterThan: {
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => {
const typedParams = params as TValidationRuleParamsIsOnOrLaterThan;
// Skip validation if value is empty
if (!value || typeof value !== "string" || value === "") {
return { valid: true };
}
// Compare dates as strings (YYYY-MM-DD format)
return { valid: value >= typedParams.date };
},
getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
const typedParams = params as TValidationRuleParamsIsOnOrLaterThan;
return t("errors.is_on_or_later_than", { date: typedParams.date });
},
},
isLaterThan: {
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => {
const typedParams = params as TValidationRuleParamsIsLaterThan;
@@ -428,21 +406,6 @@ export const validators: Record<TValidationRuleType, TValidator> = {
return t("errors.is_later_than", { date: typedParams.date });
},
},
isOnOrEarlierThan: {
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => {
const typedParams = params as TValidationRuleParamsIsOnOrEarlierThan;
// Skip validation if value is empty
if (!value || typeof value !== "string" || value === "") {
return { valid: true };
}
// Compare dates as strings (YYYY-MM-DD format)
return { valid: value <= typedParams.date };
},
getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
const typedParams = params as TValidationRuleParamsIsOnOrEarlierThan;
return t("errors.is_on_or_earlier_than", { date: typedParams.date });
},
},
isEarlierThan: {
check: (value: TResponseDataValue, params: TValidationRuleParams): TValidatorCheckResult => {
const typedParams = params as TValidationRuleParamsIsEarlierThan;
@@ -488,296 +451,53 @@ export const validators: Record<TValidationRuleType, TValidator> = {
return t("errors.is_not_between", { startDate: typedParams.startDate, endDate: typedParams.endDate });
},
},
isSelected: {
minRanked: {
check: (
value: TResponseDataValue,
params: TValidationRuleParams,
element: TSurveyElement
): TValidatorCheckResult => {
const typedParams = params as TValidationRuleParamsIsSelected;
if (!value) {
return { valid: true };
}
// Find the choice with the specified optionId
if (
(element.type !== "multipleChoiceSingle" && element.type !== "multipleChoiceMulti") ||
!("choices" in element)
) {
return { valid: true };
}
const choice = element.choices.find((c) => c.id === typedParams.optionId);
if (!choice) {
return { valid: true };
}
// Get all language variants of the choice label
const choiceLabels = Object.values(choice.label);
// Handle single select (string) and multi select (array) responses
if (element.type === "multipleChoiceSingle") {
// Single select: response is a string (choice label)
if (typeof value !== "string" || value === "") {
return { valid: true };
}
return { valid: choiceLabels.includes(value) };
} else {
// Multi select: response is an array of choice labels
if (!Array.isArray(value) || value.length === 0) {
return { valid: true };
}
// Check if any of the selected labels match the choice labels
const isSelected = value.some((selectedLabel) => choiceLabels.includes(selectedLabel));
return { valid: isSelected };
}
},
getDefaultMessage: (params: TValidationRuleParams, element: TSurveyElement, t: TFunction): string => {
const typedParams = params as TValidationRuleParamsIsSelected;
if (
(element.type !== "multipleChoiceSingle" && element.type !== "multipleChoiceMulti") ||
!("choices" in element)
) {
return t("errors.invalid_format");
}
const choice = element.choices.find((c) => c.id === typedParams.optionId);
const choiceLabel = choice
? choice.label.default || Object.values(choice.label)[0] || typedParams.optionId
: typedParams.optionId;
return t("errors.option_must_be_selected", { option: choiceLabel });
},
},
isNotSelected: {
check: (
value: TResponseDataValue,
params: TValidationRuleParams,
element: TSurveyElement
): TValidatorCheckResult => {
const typedParams = params as TValidationRuleParamsIsNotSelected;
if (!value) {
return { valid: true };
}
// Find the choice with the specified optionId
if (
(element.type !== "multipleChoiceSingle" && element.type !== "multipleChoiceMulti") ||
!("choices" in element)
) {
return { valid: true };
}
const choice = element.choices.find((c) => c.id === typedParams.optionId);
if (!choice) {
return { valid: true };
}
// Get all language variants of the choice label
const choiceLabels = Object.values(choice.label);
// Handle single select (string) and multi select (array) responses
if (element.type === "multipleChoiceSingle") {
// Single select: response is a string (choice label)
if (typeof value !== "string" || value === "") {
return { valid: true };
}
return { valid: !choiceLabels.includes(value) };
} else {
// Multi select: response is an array of choice labels
if (!Array.isArray(value) || value.length === 0) {
return { valid: true };
}
// Check if any of the selected labels match the choice labels
const isSelected = value.some((selectedLabel) => choiceLabels.includes(selectedLabel));
return { valid: !isSelected };
}
},
getDefaultMessage: (params: TValidationRuleParams, element: TSurveyElement, t: TFunction): string => {
const typedParams = params as TValidationRuleParamsIsNotSelected;
if (
(element.type !== "multipleChoiceSingle" && element.type !== "multipleChoiceMulti") ||
!("choices" in element)
) {
return t("errors.invalid_format");
}
const choice = element.choices.find((c) => c.id === typedParams.optionId);
const choiceLabel = choice
? choice.label.default || Object.values(choice.label)[0] || typedParams.optionId
: typedParams.optionId;
return t("errors.option_must_not_be_selected", { option: choiceLabel });
},
},
positionIs: {
check: (
value: TResponseDataValue,
params: TValidationRuleParams,
element: TSurveyElement
): TValidatorCheckResult => {
const typedParams = params as TValidationRuleParamsPositionIs;
const typedParams = params as TValidationRuleParamsMinRanked;
// Skip validation if value is empty
if (!value || !Array.isArray(value) || value.length === 0) {
return { valid: true };
}
if (element.type !== "ranking" || !("choices" in element)) {
if (element.type !== "ranking") {
return { valid: true };
}
// Find the position of the option in the ranking (1-indexed)
const position = value.findIndex((item) => {
// Response can be choice IDs or choice labels
if (item === typedParams.optionId) {
return true;
}
// Check if it's a label that matches the choice
const choice = element.choices.find((c) => c.id === typedParams.optionId);
if (choice) {
const choiceLabels = Object.values(choice.label);
return choiceLabels.includes(item);
}
return false;
});
// Position is 1-indexed, so add 1 to array index
const actualPosition = position === -1 ? 0 : position + 1;
return { valid: actualPosition === typedParams.position };
// Count how many options have been ranked (array length)
const rankedCount = value.length;
return { valid: rankedCount >= typedParams.min };
},
getDefaultMessage: (params: TValidationRuleParams, element: TSurveyElement, t: TFunction): string => {
const typedParams = params as TValidationRuleParamsPositionIs;
if (element.type !== "ranking" || !("choices" in element)) {
return t("errors.invalid_format");
}
const choice = element.choices.find((c) => c.id === typedParams.optionId);
const choiceLabel = choice
? choice.label.default || Object.values(choice.label)[0] || typedParams.optionId
: typedParams.optionId;
return t("errors.position_must_be", { option: choiceLabel, position: typedParams.position });
getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
const typedParams = params as TValidationRuleParamsMinRanked;
return t("errors.minimum_options_ranked", { min: typedParams.min });
},
},
positionIsHigherThan: {
minRowsAnswered: {
check: (
value: TResponseDataValue,
params: TValidationRuleParams,
element: TSurveyElement
): TValidatorCheckResult => {
const typedParams = params as TValidationRuleParamsPositionIsHigherThan;
if (!value || !Array.isArray(value) || value.length === 0) {
const typedParams = params as TValidationRuleParamsMinRowsAnswered;
// Skip validation if value is empty
if (!value || typeof value !== "object" || Array.isArray(value) || value === null) {
return { valid: true };
}
if (element.type !== "ranking" || !("choices" in element)) {
return { valid: true };
}
// Find the position of the option in the ranking (1-indexed)
const position = value.findIndex((item) => {
if (item === typedParams.optionId) {
return true;
}
const choice = element.choices.find((c) => c.id === typedParams.optionId);
if (choice) {
const choiceLabels = Object.values(choice.label);
return choiceLabels.includes(item);
}
return false;
});
// Position is 1-indexed, so add 1 to array index
// Higher position means lower position number (better rank)
const actualPosition = position === -1 ? 0 : position + 1;
return { valid: actualPosition > 0 && actualPosition < typedParams.position };
},
getDefaultMessage: (params: TValidationRuleParams, element: TSurveyElement, t: TFunction): string => {
const typedParams = params as TValidationRuleParamsPositionIsHigherThan;
if (element.type !== "ranking" || !("choices" in element)) {
return t("errors.invalid_format");
}
const choice = element.choices.find((c) => c.id === typedParams.optionId);
const choiceLabel = choice
? choice.label.default || Object.values(choice.label)[0] || typedParams.optionId
: typedParams.optionId;
return t("errors.position_must_be_higher_than", {
option: choiceLabel,
position: typedParams.position,
});
},
},
positionIsLowerThan: {
check: (
value: TResponseDataValue,
params: TValidationRuleParams,
element: TSurveyElement
): TValidatorCheckResult => {
const typedParams = params as TValidationRuleParamsPositionIsLowerThan;
if (!value || !Array.isArray(value) || value.length === 0) {
return { valid: true };
}
if (element.type !== "ranking" || !("choices" in element)) {
return { valid: true };
}
// Find the position of the option in the ranking (1-indexed)
const position = value.findIndex((item) => {
if (item === typedParams.optionId) {
return true;
}
const choice = element.choices.find((c) => c.id === typedParams.optionId);
if (choice) {
const choiceLabels = Object.values(choice.label);
return choiceLabels.includes(item);
}
return false;
});
// Position is 1-indexed, so add 1 to array index
// Lower position means higher position number (worse rank)
const actualPosition = position === -1 ? 0 : position + 1;
return { valid: actualPosition > typedParams.position };
},
getDefaultMessage: (params: TValidationRuleParams, element: TSurveyElement, t: TFunction): string => {
const typedParams = params as TValidationRuleParamsPositionIsLowerThan;
if (element.type !== "ranking" || !("choices" in element)) {
return t("errors.invalid_format");
}
const choice = element.choices.find((c) => c.id === typedParams.optionId);
const choiceLabel = choice
? choice.label.default || Object.values(choice.label)[0] || typedParams.optionId
: typedParams.optionId;
return t("errors.position_must_be_lower_than", { option: choiceLabel, position: typedParams.position });
},
},
answersProvidedGreaterThan: {
check: (
value: TResponseDataValue,
params: TValidationRuleParams,
element: TSurveyElement
): TValidatorCheckResult => {
const typedParams = params as TValidationRuleParamsAnswersProvidedGreaterThan;
if (element.type !== "matrix") {
return { valid: true };
}
// Matrix responses are Record<string, string> where keys are row labels and values are column labels
if (!value || typeof value !== "object" || Array.isArray(value) || value === null) {
return { valid: true };
}
// Count non-empty answers (rows that have been answered)
const answeredCount = Object.values(value).filter(
(v) => v !== "" && v !== null && v !== undefined
).length;
return { valid: answeredCount > typedParams.min };
return { valid: answeredCount >= typedParams.min };
},
getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
const typedParams = params as TValidationRuleParamsAnswersProvidedGreaterThan;
return t("errors.answers_provided_must_be_greater_than", { min: typedParams.min });
},
},
answersProvidedSmallerThan: {
check: (
value: TResponseDataValue,
params: TValidationRuleParams,
element: TSurveyElement
): TValidatorCheckResult => {
const typedParams = params as TValidationRuleParamsAnswersProvidedSmallerThan;
if (element.type !== "matrix") {
return { valid: true };
}
// Matrix responses are Record<string, string> where keys are row labels and values are column labels
if (!value || typeof value !== "object" || Array.isArray(value) || value === null) {
return { valid: true };
}
// Count non-empty answers (rows that have been answered)
const answeredCount = Object.values(value).filter(
(v) => v !== "" && v !== null && v !== undefined
).length;
return { valid: answeredCount < typedParams.max };
},
getDefaultMessage: (params: TValidationRuleParams, _element: TSurveyElement, t: TFunction): string => {
const typedParams = params as TValidationRuleParamsAnswersProvidedSmallerThan;
return t("errors.answers_provided_must_be_smaller_than", { max: typedParams.max });
const typedParams = params as TValidationRuleParamsMinRowsAnswered;
return t("errors.minimum_rows_answered", { min: typedParams.min });
},
},
fileSizeAtLeast: {

View File

@@ -12,7 +12,6 @@ import {
ZValidationRulesForFileUpload,
ZValidationRulesForMatrix,
ZValidationRulesForMultipleChoiceMulti,
ZValidationRulesForMultipleChoiceSingle,
ZValidationRulesForNPS,
ZValidationRulesForOpenText,
ZValidationRulesForPictureSelection,
@@ -163,7 +162,6 @@ export const ZSurveyMultipleChoiceSingleElement = ZSurveyElementBase.extend({
.min(2, { message: "Multiple Choice Element must have at least two choices" }),
shuffleOption: ZShuffleOption.optional(),
otherOptionPlaceholder: ZI18nString.optional(),
validationRules: ZValidationRulesForMultipleChoiceSingle.optional(),
});
// Multiple Choice Multi Element

View File

@@ -27,23 +27,14 @@ export const ZValidationRuleType = z.enum([
"minSelections",
"maxSelections",
// Single select rules
"isSelected",
"isNotSelected",
// Ranking rules
"positionIs",
"positionIsHigherThan",
"positionIsLowerThan",
"minRanked",
// Matrix rules
"answersProvidedGreaterThan",
"answersProvidedSmallerThan",
"minRowsAnswered",
// Date rules
"isOnOrLaterThan",
"isLaterThan",
"isOnOrEarlierThan",
"isEarlierThan",
"isBetween",
"isNotBetween",
@@ -125,18 +116,10 @@ export const ZValidationRuleParamsIsLessThan = z.object({
max: z.number(),
});
export const ZValidationRuleParamsIsOnOrLaterThan = z.object({
date: z.string(), // YYYY-MM-DD format
});
export const ZValidationRuleParamsIsLaterThan = z.object({
date: z.string(), // YYYY-MM-DD format
});
export const ZValidationRuleParamsIsOnOrEarlierThan = z.object({
date: z.string(), // YYYY-MM-DD format
});
export const ZValidationRuleParamsIsEarlierThan = z.object({
date: z.string(), // YYYY-MM-DD format
});
@@ -151,35 +134,12 @@ export const ZValidationRuleParamsIsNotBetween = z.object({
endDate: z.string(), // YYYY-MM-DD format
});
export const ZValidationRuleParamsIsSelected = z.object({
optionId: z.string().min(1),
export const ZValidationRuleParamsMinRanked = z.object({
min: z.number().min(1),
});
export const ZValidationRuleParamsIsNotSelected = z.object({
optionId: z.string().min(1),
});
export const ZValidationRuleParamsPositionIs = z.object({
optionId: z.string().min(1),
position: z.number().min(1),
});
export const ZValidationRuleParamsPositionIsHigherThan = z.object({
optionId: z.string().min(1),
position: z.number().min(1),
});
export const ZValidationRuleParamsPositionIsLowerThan = z.object({
optionId: z.string().min(1),
position: z.number().min(1),
});
export const ZValidationRuleParamsAnswersProvidedGreaterThan = z.object({
min: z.number().min(0),
});
export const ZValidationRuleParamsAnswersProvidedSmallerThan = z.object({
max: z.number().min(0),
export const ZValidationRuleParamsMinRowsAnswered = z.object({
min: z.number().min(1),
});
// File upload rule params
@@ -221,19 +181,12 @@ export const ZValidationRuleParams = z.union([
ZValidationRuleParamsIsLessThan,
ZValidationRuleParamsMinSelections,
ZValidationRuleParamsMaxSelections,
ZValidationRuleParamsIsOnOrLaterThan,
ZValidationRuleParamsIsLaterThan,
ZValidationRuleParamsIsOnOrEarlierThan,
ZValidationRuleParamsIsEarlierThan,
ZValidationRuleParamsIsBetween,
ZValidationRuleParamsIsNotBetween,
ZValidationRuleParamsIsSelected,
ZValidationRuleParamsIsNotSelected,
ZValidationRuleParamsPositionIs,
ZValidationRuleParamsPositionIsHigherThan,
ZValidationRuleParamsPositionIsLowerThan,
ZValidationRuleParamsAnswersProvidedGreaterThan,
ZValidationRuleParamsAnswersProvidedSmallerThan,
ZValidationRuleParamsMinRanked,
ZValidationRuleParamsMinRowsAnswered,
ZValidationRuleParamsFileSizeAtLeast,
ZValidationRuleParamsFileSizeAtMost,
ZValidationRuleParamsFileExtensionIs,
@@ -261,27 +214,12 @@ export type TValidationRuleParamsIsLongerThan = z.infer<typeof ZValidationRulePa
export type TValidationRuleParamsIsShorterThan = z.infer<typeof ZValidationRuleParamsIsShorterThan>;
export type TValidationRuleParamsIsGreaterThan = z.infer<typeof ZValidationRuleParamsIsGreaterThan>;
export type TValidationRuleParamsIsLessThan = z.infer<typeof ZValidationRuleParamsIsLessThan>;
export type TValidationRuleParamsIsOnOrLaterThan = z.infer<typeof ZValidationRuleParamsIsOnOrLaterThan>;
export type TValidationRuleParamsIsLaterThan = z.infer<typeof ZValidationRuleParamsIsLaterThan>;
export type TValidationRuleParamsIsOnOrEarlierThan = z.infer<typeof ZValidationRuleParamsIsOnOrEarlierThan>;
export type TValidationRuleParamsIsEarlierThan = z.infer<typeof ZValidationRuleParamsIsEarlierThan>;
export type TValidationRuleParamsIsBetween = z.infer<typeof ZValidationRuleParamsIsBetween>;
export type TValidationRuleParamsIsNotBetween = z.infer<typeof ZValidationRuleParamsIsNotBetween>;
export type TValidationRuleParamsIsSelected = z.infer<typeof ZValidationRuleParamsIsSelected>;
export type TValidationRuleParamsIsNotSelected = z.infer<typeof ZValidationRuleParamsIsNotSelected>;
export type TValidationRuleParamsPositionIs = z.infer<typeof ZValidationRuleParamsPositionIs>;
export type TValidationRuleParamsPositionIsHigherThan = z.infer<
typeof ZValidationRuleParamsPositionIsHigherThan
>;
export type TValidationRuleParamsPositionIsLowerThan = z.infer<
typeof ZValidationRuleParamsPositionIsLowerThan
>;
export type TValidationRuleParamsAnswersProvidedGreaterThan = z.infer<
typeof ZValidationRuleParamsAnswersProvidedGreaterThan
>;
export type TValidationRuleParamsAnswersProvidedSmallerThan = z.infer<
typeof ZValidationRuleParamsAnswersProvidedSmallerThan
>;
export type TValidationRuleParamsMinRanked = z.infer<typeof ZValidationRuleParamsMinRanked>;
export type TValidationRuleParamsMinRowsAnswered = z.infer<typeof ZValidationRuleParamsMinRowsAnswered>;
export type TValidationRuleParamsFileSizeAtLeast = z.infer<typeof ZValidationRuleParamsFileSizeAtLeast>;
export type TValidationRuleParamsFileSizeAtMost = z.infer<typeof ZValidationRuleParamsFileSizeAtMost>;
export type TValidationRuleParamsFileExtensionIs = z.infer<typeof ZValidationRuleParamsFileExtensionIs>;
@@ -397,24 +335,12 @@ export const ZValidationRule = z.discriminatedUnion("type", [
params: ZValidationRuleParamsIsLessThan,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("isOnOrLaterThan"),
params: ZValidationRuleParamsIsOnOrLaterThan,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("isLaterThan"),
params: ZValidationRuleParamsIsLaterThan,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("isOnOrEarlierThan"),
params: ZValidationRuleParamsIsOnOrEarlierThan,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("isEarlierThan"),
@@ -435,44 +361,14 @@ export const ZValidationRule = z.discriminatedUnion("type", [
}),
z.object({
id: z.string(),
type: z.literal("isSelected"),
params: ZValidationRuleParamsIsSelected,
type: z.literal("minRanked"),
params: ZValidationRuleParamsMinRanked,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("isNotSelected"),
params: ZValidationRuleParamsIsNotSelected,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("positionIs"),
params: ZValidationRuleParamsPositionIs,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("positionIsHigherThan"),
params: ZValidationRuleParamsPositionIsHigherThan,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("positionIsLowerThan"),
params: ZValidationRuleParamsPositionIsLowerThan,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("answersProvidedGreaterThan"),
params: ZValidationRuleParamsAnswersProvidedGreaterThan,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("answersProvidedSmallerThan"),
params: ZValidationRuleParamsAnswersProvidedSmallerThan,
type: z.literal("minRowsAnswered"),
params: ZValidationRuleParamsMinRowsAnswered,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
@@ -527,26 +423,22 @@ const OPEN_TEXT_RULES = [
"isLessThan",
] as const;
const MULTIPLE_CHOICE_SINGLE_RULES = ["isSelected", "isNotSelected"] as const;
const MULTIPLE_CHOICE_SINGLE_RULES = [] as const;
const MULTIPLE_CHOICE_MULTI_RULES = [
"minSelections",
"maxSelections",
"isSelected",
"isNotSelected",
] as const;
const RATING_RULES = [] as const;
const NPS_RULES = [] as const;
const DATE_RULES = [
"isOnOrLaterThan",
"isLaterThan",
"isOnOrEarlierThan",
"isEarlierThan",
"isBetween",
"isNotBetween",
] as const;
const CONSENT_RULES = [] as const;
const MATRIX_RULES = ["answersProvidedGreaterThan", "answersProvidedSmallerThan"] as const;
const RANKING_RULES = ["positionIs", "positionIsHigherThan", "positionIsLowerThan"] as const;
const MATRIX_RULES = ["minRowsAnswered"] as const;
const RANKING_RULES = ["minRanked"] as const;
const FILE_UPLOAD_RULES = ["fileSizeAtLeast", "fileSizeAtMost", "fileExtensionIs", "fileExtensionIsNot"] as const;
const PICTURE_SELECTION_RULES = ["minSelections", "maxSelections"] as const;
const ADDRESS_RULES = [] as const;
@@ -725,23 +617,6 @@ export const ZValidationRulesForOpenText: z.ZodType<TValidationRulesForOpenText>
])
);
export const ZValidationRulesForMultipleChoiceSingle: z.ZodType<TValidationRulesForMultipleChoiceSingle> =
z.array(
z.discriminatedUnion("type", [
z.object({
id: z.string(),
type: z.literal("isSelected"),
params: ZValidationRuleParamsIsSelected,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("isNotSelected"),
params: ZValidationRuleParamsIsNotSelected,
customErrorMessage: ZI18nString.optional(),
}),
])
);
export const ZValidationRulesForMultipleChoiceMulti: z.ZodType<TValidationRulesForMultipleChoiceMulti> =
z.array(
@@ -758,18 +633,6 @@ export const ZValidationRulesForMultipleChoiceMulti: z.ZodType<TValidationRulesF
params: ZValidationRuleParamsMaxSelections,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("isSelected"),
params: ZValidationRuleParamsIsSelected,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("isNotSelected"),
params: ZValidationRuleParamsIsNotSelected,
customErrorMessage: ZI18nString.optional(),
}),
])
);
@@ -779,24 +642,12 @@ export const ZValidationRulesForNPS: z.ZodType<TValidationRulesForNPS> = z.array
export const ZValidationRulesForDate: z.ZodType<TValidationRulesForDate> = z.array(
z.discriminatedUnion("type", [
z.object({
id: z.string(),
type: z.literal("isOnOrLaterThan"),
params: ZValidationRuleParamsIsOnOrLaterThan,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("isLaterThan"),
params: ZValidationRuleParamsIsLaterThan,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("isOnOrEarlierThan"),
params: ZValidationRuleParamsIsOnOrEarlierThan,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("isEarlierThan"),
@@ -824,14 +675,8 @@ export const ZValidationRulesForMatrix: z.ZodType<TValidationRulesForMatrix> = z
z.discriminatedUnion("type", [
z.object({
id: z.string(),
type: z.literal("answersProvidedGreaterThan"),
params: ZValidationRuleParamsAnswersProvidedGreaterThan,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("answersProvidedSmallerThan"),
params: ZValidationRuleParamsAnswersProvidedSmallerThan,
type: z.literal("minRowsAnswered"),
params: ZValidationRuleParamsMinRowsAnswered,
customErrorMessage: ZI18nString.optional(),
}),
])
@@ -841,20 +686,8 @@ export const ZValidationRulesForRanking: z.ZodType<TValidationRulesForRanking> =
z.discriminatedUnion("type", [
z.object({
id: z.string(),
type: z.literal("positionIs"),
params: ZValidationRuleParamsPositionIs,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("positionIsHigherThan"),
params: ZValidationRuleParamsPositionIsHigherThan,
customErrorMessage: ZI18nString.optional(),
}),
z.object({
id: z.string(),
type: z.literal("positionIsLowerThan"),
params: ZValidationRuleParamsPositionIsLowerThan,
type: z.literal("minRanked"),
params: ZValidationRuleParamsMinRanked,
customErrorMessage: ZI18nString.optional(),
}),
])