WIP: adds survey refine for advanced logic

This commit is contained in:
Piyush Gupta
2024-09-05 22:05:42 +05:30
parent 97b04f9e43
commit 0535581f6d
8 changed files with 576 additions and 427 deletions

View File

@@ -15,7 +15,6 @@ import {
TActionTextVariableCalculateOperator,
TActionVariableValueType,
TSurveyAdvancedLogic,
ZAction,
} from "@formbricks/types/surveys/logic";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
import {
@@ -70,17 +69,19 @@ export function AdvancedLogicEditorActions({
});
};
function updateAction(actionIdx: number, updateActionBody: Partial<TAction>) {
const handleObjectiveChange = (actionIdx: number, objective: TActionObjective) => {
const action = actions[actionIdx];
const actionBody = getUpdatedActionBody(action, updateActionBody);
const parsedActionBodyResult = ZAction.safeParse(actionBody);
if (!parsedActionBodyResult.success) {
console.error("Failed to update action", parsedActionBodyResult.error.errors);
return;
}
handleActionsChange("update", actionIdx, parsedActionBodyResult.data);
}
const actionBody = getUpdatedActionBody(action, objective);
handleActionsChange("update", actionIdx, actionBody);
};
const handleValuesChange = (actionIdx: number, values: Partial<TAction>) => {
const action = actions[actionIdx];
const actionBody = { ...action, ...values } as TAction;
handleActionsChange("update", actionIdx, actionBody);
};
console.log("actions", actions);
return (
<div className="flex grow gap-2">
<CornerDownRightIcon className="mt-3 h-4 w-4 shrink-0" />
@@ -95,29 +96,42 @@ export function AdvancedLogicEditorActions({
options={actionObjectiveOptions}
value={action.objective}
onChangeValue={(val: TActionObjective) => {
updateAction(idx, {
objective: val,
});
handleObjectiveChange(idx, val);
}}
comboboxClasses="grow"
/>
<InputCombobox
key="target"
showSearch={false}
options={
action.objective === "calculate"
? getActionVariableOptions(localSurvey)
: getActionTargetOptions(action, localSurvey, questionIdx)
}
value={action.objective === "calculate" ? action.variableId : action.target}
onChangeValue={(val: string) => {
updateAction(idx, {
...(action.objective === "calculate" ? { variableId: val } : { target: val }),
});
}}
/>
{action.objective !== "calculate" && (
<InputCombobox
key="target"
showSearch={false}
options={getActionTargetOptions(action, localSurvey, questionIdx)}
value={action.target}
onChangeValue={(val: string) => {
handleValuesChange(idx, {
target: val,
});
}}
comboboxClasses="w-40"
/>
)}
{action.objective === "calculate" && (
<>
<InputCombobox
key="variableId"
showSearch={false}
options={getActionVariableOptions(localSurvey)}
value={action.variableId}
onChangeValue={(val: string) => {
handleValuesChange(idx, {
variableId: val,
value: {
type: "static",
value: "",
},
});
}}
comboboxClasses="w-40"
/>
<InputCombobox
key="attribute"
showSearch={false}
@@ -128,10 +142,11 @@ export function AdvancedLogicEditorActions({
onChangeValue={(
val: TActionTextVariableCalculateOperator | TActionNumberVariableCalculateOperator
) => {
updateAction(idx, {
handleValuesChange(idx, {
operator: val,
});
}}
comboboxClasses="w-20"
/>
<InputCombobox
key="value"
@@ -143,20 +158,20 @@ export function AdvancedLogicEditorActions({
type: localSurvey.variables.find((v) => v.id === action.variableId)?.type || "text",
}}
groupedOptions={getActionValueOptions(action.variableId, localSurvey, questionIdx)}
onChangeValue={(val: string | number, option) => {
onChangeValue={(val, option, fromInput) => {
const fieldType = option?.meta?.type as TActionVariableValueType;
if (fieldType !== "static") {
updateAction(idx, {
if (!fromInput && fieldType !== "static") {
handleValuesChange(idx, {
value: {
type: fieldType,
value: val as string,
},
});
} else if (fieldType === "static") {
updateAction(idx, {
} else if (fromInput) {
handleValuesChange(idx, {
value: {
type: fieldType,
type: "static",
value: val as string,
},
});

View File

@@ -365,6 +365,30 @@ export const ruleEngine = {
label: "does not equal",
value: ZSurveyLogicCondition.Enum.doesNotEqual,
},
{
label: "contains",
value: ZSurveyLogicCondition.Enum.contains,
},
{
label: "does not contain",
value: ZSurveyLogicCondition.Enum.doesNotContain,
},
{
label: "starts with",
value: ZSurveyLogicCondition.Enum.startsWith,
},
{
label: "does not start with",
value: ZSurveyLogicCondition.Enum.doesNotStartWith,
},
{
label: "ends with",
value: ZSurveyLogicCondition.Enum.endsWith,
},
{
label: "does not end with",
value: ZSurveyLogicCondition.Enum.doesNotEndWith,
},
],
},
number: {
@@ -406,6 +430,30 @@ export const ruleEngine = {
label: "does not equal",
value: ZSurveyLogicCondition.Enum.doesNotEqual,
},
{
label: "contains",
value: ZSurveyLogicCondition.Enum.contains,
},
{
label: "does not contain",
value: ZSurveyLogicCondition.Enum.doesNotContain,
},
{
label: "starts with",
value: ZSurveyLogicCondition.Enum.startsWith,
},
{
label: "does not start with",
value: ZSurveyLogicCondition.Enum.doesNotStartWith,
},
{
label: "ends with",
value: ZSurveyLogicCondition.Enum.endsWith,
},
{
label: "does not end with",
value: ZSurveyLogicCondition.Enum.doesNotEndWith,
},
],
},
};

View File

@@ -725,15 +725,19 @@ export const getActionTargetOptions = (
localSurvey: TSurvey,
currQuestionIdx: number
): TComboboxOption[] => {
const questionOptions = localSurvey.questions
.filter((_, idx) => idx !== currQuestionIdx)
.map((question) => {
return {
icon: questionIconMapping[question.type],
label: getLocalizedValue(question.headline, "default"),
value: question.id,
};
});
let questions = localSurvey.questions.filter((_, idx) => idx !== currQuestionIdx);
if (action.objective === "requireAnswer") {
questions = questions.filter((question) => !question.required);
}
const questionOptions = questions.map((question) => {
return {
icon: questionIconMapping[question.type],
label: getLocalizedValue(question.headline, "default"),
value: question.id,
};
});
if (action.objective === "requireAnswer") return questionOptions;

View File

@@ -1,6 +1,7 @@
import { createId } from "@paralleldrive/cuid2";
import {
TAction,
TActionObjective,
TConditionGroup,
TSingleCondition,
TSurveyAdvancedLogic,
@@ -174,37 +175,28 @@ export const updateCondition = (
}
};
export const getUpdatedActionBody = (action: TAction, update: Partial<TAction>) => {
if (update.objective) {
if (update.objective === action.objective) return action;
switch (update.objective) {
case "calculate":
return {
...action,
...update,
objective: "calculate",
variableId: "",
operator: "assign",
value: update.value ? { ...update.value } : { type: "static", value: "" },
};
case "requireAnswer":
return {
...action,
...update,
objective: "requireAnswer",
target: "",
};
case "jumpToQuestion":
return {
...action,
...update,
objective: "jumpToQuestion",
target: "",
};
}
export const getUpdatedActionBody = (action: TAction, objective: TActionObjective): TAction => {
if (objective === action.objective) return action;
switch (objective) {
case "calculate":
return {
id: action.id,
objective: "calculate",
variableId: "",
operator: "assign",
value: { type: "static", value: "" },
};
case "requireAnswer":
return {
id: action.id,
objective: "requireAnswer",
target: "",
};
case "jumpToQuestion":
return {
id: action.id,
objective: "jumpToQuestion",
target: "",
};
}
return {
...action,
...update,
};
};

View File

@@ -1,291 +1,5 @@
import { z } from "zod";
// export const ZSurveyOpenTextQuestionInputType = z.enum(["text", "email", "url", "number", "phone"]);
// export enum TSurveyQuestionTypeEnum {
// FileUpload = "fileUpload",
// OpenText = "openText",
// MultipleChoiceSingle = "multipleChoiceSingle",
// MultipleChoiceMulti = "multipleChoiceMulti",
// NPS = "nps",
// CTA = "cta",
// Rating = "rating",
// Consent = "consent",
// PictureSelection = "pictureSelection",
// Cal = "cal",
// Date = "date",
// Matrix = "matrix",
// Address = "address",
// }
// export type TSurveyLogicCondition = z.infer<typeof ZSurveyLogicCondition>;
// export type TDyanmicLogicField = z.infer<typeof ZDyanmicLogicField>;
// const ZMatchValueBase = z.object({
// type: z.enum(["static", "dynamic"]),
// });
// const ZMatchValueStatic = ZMatchValueBase.extend({
// type: z.literal("static"),
// value: z.union([z.string(), z.array(z.string()), z.number()]),
// });
// const ZMatchValueDynamic = ZMatchValueBase.extend({
// type: z.literal("dynamic"),
// id: z.string(),
// fieldType: ZDyanmicLogicField,
// });
// const ZMatchValue = z.union([ZMatchValueStatic, ZMatchValueDynamic]);
// const ZLogicalConnector = z.enum(["and", "or"]);
// export type TLogicalConnector = z.infer<typeof ZLogicalConnector>;
// const ZConditionBase = z.object({
// id: z.string().cuid2(),
// connector: ZLogicalConnector.nullable(),
// type: ZDyanmicLogicField,
// conditionValue: z.string(),
// conditionOperator: ZSurveyLogicCondition.nullable(),
// matchValue: ZMatchValue.nullable(),
// });
// export type TConditionBase = z.infer<typeof ZConditionBase>;
// const ZConditionQuestionBase = ZConditionBase.extend({
// type: z.literal("question"),
// questionType: z.nativeEnum(TSurveyQuestionTypeEnum),
// });
// export type TConditionQuestionBase = z.infer<typeof ZConditionQuestionBase>;
// const ZOpenTextConditionBase = ZConditionQuestionBase.extend({
// questionType: z.literal(TSurveyQuestionTypeEnum.OpenText),
// inputType: ZSurveyOpenTextQuestionInputType.optional().default("text"),
// });
// const ZOpenTextStringConditon = ZOpenTextConditionBase.extend({
// inputType: z.enum(["text", "email", "url", "phone"]),
// conditionOperator: z.enum([
// "equals",
// "doesNotEqual",
// "contains",
// "doesNotContain",
// "startsWith",
// "doesNotStartWith",
// "endsWith",
// "doesNotEndWith",
// "isSubmitted",
// "isSkipped",
// ]),
// matchValue: z.string().optional(),
// });
// const ZOpenTextNumberConditon = ZOpenTextConditionBase.extend({
// inputType: z.literal("number"),
// conditionOperator: z.enum([
// "equals",
// "doesNotEqual",
// "isGreaterThan",
// "isLessThan",
// "isGreaterThanOrEqual",
// "isLessThanOrEqual",
// "isSubmitted",
// "isSkipped",
// ]),
// matchValue: z.number().optional(),
// });
// const ZOpenTextCondition = z.union([ZOpenTextStringConditon, ZOpenTextNumberConditon]);
// const ZMultipleChoiceSingleCondition = ZConditionQuestionBase.extend({
// questionType: z.literal(TSurveyQuestionTypeEnum.MultipleChoiceSingle),
// conditionOperator: z.enum(["equals", "doesNotEqual", "equalsOneOf", "isSubmitted", "isSkipped"]),
// });
// const ZMultipleChoiceMultiCondition = ZConditionQuestionBase.extend({
// questionType: z.literal(TSurveyQuestionTypeEnum.MultipleChoiceMulti),
// conditionOperator: z.enum([
// "equals",
// "doesNotEqual",
// "includesAllOf",
// "includesOneOf",
// "isSubmitted",
// "isSkipped",
// ]),
// });
// const ZPictureSelectionCondition = ZConditionQuestionBase.extend({
// questionType: z.literal(TSurveyQuestionTypeEnum.PictureSelection),
// conditionOperator: z.enum([
// "equals",
// "doesNotEqual",
// "includesAllOf",
// "includesOneOf",
// "isSubmitted",
// "isSkipped",
// ]),
// });
// const ZRatingCondition = ZConditionQuestionBase.extend({
// questionType: z.literal(TSurveyQuestionTypeEnum.Rating),
// conditionOperator: z.enum([
// "equals",
// "doesNotEqual",
// "isGreaterThan",
// "isLessThan",
// "isGreaterThanOrEqual",
// "isLessThanOrEqual",
// "isSubmitted",
// "isSkipped",
// ]),
// matchValue: z.number().optional(),
// });
// const ZNPSCondition = ZConditionQuestionBase.extend({
// questionType: z.literal(TSurveyQuestionTypeEnum.NPS),
// conditionOperator: z.enum([
// "equals",
// "doesNotEqual",
// "isGreaterThan",
// "isLessThan",
// "isGreaterThanOrEqual",
// "isLessThanOrEqual",
// "isSubmitted",
// "isSkipped",
// ]),
// matchValue: z.number().optional(),
// });
// const ZCTACondition = ZConditionQuestionBase.extend({
// questionType: z.literal(TSurveyQuestionTypeEnum.CTA),
// conditionOperator: z.enum(["isClicked", "isSkipped"]),
// matchValue: z.undefined(),
// });
// const ZConsentCondition = ZConditionQuestionBase.extend({
// questionType: z.literal(TSurveyQuestionTypeEnum.Consent),
// conditionOperator: z.enum(["isAccepted", "isSkipped"]),
// matchValue: z.undefined(),
// });
// const ZDateCondition = ZConditionQuestionBase.extend({
// questionType: z.literal(TSurveyQuestionTypeEnum.Date),
// conditionOperator: z.enum(["equals", "doesNotEqual", "isBefore", "isAfter", "isSubmitted", "isSkipped"]),
// matchValue: z.string().optional(),
// });
// const ZFileUploadCondition = ZConditionQuestionBase.extend({
// questionType: z.literal(TSurveyQuestionTypeEnum.FileUpload),
// conditionOperator: z.enum(["isSubmitted", "isSkipped"]),
// matchValue: z.undefined(),
// });
// const ZCalCondition = ZConditionQuestionBase.extend({
// questionType: z.literal(TSurveyQuestionTypeEnum.Cal),
// conditionOperator: z.enum(["isBooked", "isSkipped"]),
// matchValue: z.undefined(),
// });
// const ZMatrixCondition = ZConditionQuestionBase.extend({
// questionType: z.literal(TSurveyQuestionTypeEnum.Matrix),
// conditionOperator: z.enum(["isPartiallySubmitted", "isCompletelySubmitted", "isSkipped"]),
// matchValue: z.undefined(),
// });
// const ZAddressCondition = ZConditionQuestionBase.extend({
// questionType: z.literal(TSurveyQuestionTypeEnum.Address),
// conditionOperator: z.enum(["isSubmitted", "isSkipped"]),
// matchValue: z.undefined(),
// });
// const ZConditionQuestion = z.union([
// ZOpenTextCondition,
// ZMultipleChoiceSingleCondition,
// ZMultipleChoiceMultiCondition,
// ZPictureSelectionCondition,
// ZRatingCondition,
// ZNPSCondition,
// ZCTACondition,
// ZConsentCondition,
// ZDateCondition,
// ZFileUploadCondition,
// ZCalCondition,
// ZMatrixCondition,
// ZAddressCondition,
// ]);
// const ZConditionVariableBase = ZConditionBase.extend({
// type: z.literal("variable"),
// variableType: z.enum(["number", "text"]),
// });
// const ZConditionTextVariable = ZConditionVariableBase.extend({
// variableType: z.literal("text"),
// conditionOperator: z.enum([
// "equals",
// "doesNotEqual",
// "contains",
// "doesNotContain",
// "startsWith",
// "doesNotStartWith",
// "endsWith",
// "doesNotEndWith",
// ]),
// });
// const ZConditionNumberVariable = ZConditionVariableBase.extend({
// variableType: z.literal("number"),
// conditionOperator: z.enum([
// "equals",
// "doesNotEqual",
// "isGreaterThan",
// "isLessThan",
// "isGreaterThanOrEqual",
// "isLessThanOrEqual",
// ]),
// });
// const ZConditionVariable = z.union([ZConditionTextVariable, ZConditionNumberVariable]);
// const ZConditionAttributeClass = ZConditionBase.extend({
// type: z.literal("attributeClass"),
// conditionOperator: z.enum([
// "equals",
// "doesNotEqual",
// "contains",
// "doesNotContain",
// "startsWith",
// "doesNotStartWith",
// "endsWith",
// "doesNotEndWith",
// ]),
// });
// const ZConditionHiddenField = ZConditionBase.extend({
// type: z.literal("hiddenField"),
// conditionOperator: z.enum([
// "equals",
// "doesNotEqual",
// "contains",
// "doesNotContain",
// "startsWith",
// "doesNotStartWith",
// "endsWith",
// "doesNotEndWith",
// ]),
// });
// const ZCondition = z.union([
// ZConditionQuestion,
// ZConditionVariable,
// ZConditionAttributeClass,
// ZConditionHiddenField,
// ]);
// export type TCondition = z.infer<typeof ZCondition>;
export const ZSurveyLogicCondition = z.enum([
"equals",
"doesNotEqual",
@@ -315,31 +29,35 @@ export const ZSurveyLogicCondition = z.enum([
export const ZDyanmicLogicField = z.enum(["question", "variable", "hiddenField"]);
export const ZActionObjective = z.enum(["calculate", "requireAnswer", "jumpToQuestion"]);
export const ZActionTextVariableCalculateOperator = z.enum(["assign", "concat"]);
export const ZActionNumberVariableCalculateOperator = z.enum([
"add",
"subtract",
"multiply",
"divide",
"assign",
]);
export const ZActionTextVariableCalculateOperator = z.enum(["assign", "concat"], {
message: "Invalid operator for a text variable",
});
export const ZActionNumberVariableCalculateOperator = z.enum(
["add", "subtract", "multiply", "divide", "assign"],
{ message: "Invalid operator for a number variable" }
);
const ZDynamicQuestion = z.object({
type: z.literal("question"),
value: z.string(),
value: z.string().min(1, "Question id cannot be empty"),
});
const ZDynamicVariable = z.object({
type: z.literal("variable"),
value: z.string().cuid2(),
value: z
.string()
.cuid2({ message: "Variable id must be a valid cuid" })
.min(1, "Variable id cannot be empty"),
});
const ZDynamicHiddenField = z.object({
type: z.literal("hiddenField"),
value: z.string(),
value: z.string().min(1, "Hidden field id cannot be empty"),
});
const ZDynamicLogicFieldValue = z.union([ZDynamicQuestion, ZDynamicVariable, ZDynamicHiddenField]);
const ZDynamicLogicFieldValue = z.union([ZDynamicQuestion, ZDynamicVariable, ZDynamicHiddenField], {
message: "Invalid dynamic field value",
});
export type TSurveyLogicCondition = z.infer<typeof ZSurveyLogicCondition>;
export type TDyanmicLogicField = z.infer<typeof ZDyanmicLogicField>;
@@ -404,23 +122,25 @@ const ZActionCalculateBase = ZActionBase.extend({
variableId: z.string(),
});
const ZActionCalculateText = ZActionCalculateBase.extend({
export const ZActionCalculateText = ZActionCalculateBase.extend({
operator: ZActionTextVariableCalculateOperator,
value: z.union([
z.object({
type: z.literal("static"),
value: z.string(),
value: z
.string({ message: "Value must be a string for text variable" })
.min(1, "please enter a value in logic field"),
}),
ZDynamicLogicFieldValue,
]),
});
const ZActionCalculateNumber = ZActionCalculateBase.extend({
export const ZActionCalculateNumber = ZActionCalculateBase.extend({
operator: ZActionNumberVariableCalculateOperator,
value: z.union([
z.object({
type: z.literal("static"),
value: z.number(),
value: z.number({ message: "Value must be a number for number variable" }),
}),
ZDynamicLogicFieldValue,
]),

View File

@@ -5,11 +5,21 @@ import { ZAllowedFileExtension, ZColor, ZId, ZPlacement } from "../common";
import { ZLanguage } from "../product";
import { ZSegment } from "../segment";
import { ZBaseStyling } from "../styling";
import { ZSurveyAdvancedLogic } from "./logic";
import {
type TAction,
type TConditionGroup,
type TSingleCondition,
type TSurveyAdvancedLogic,
type TSurveyLogicCondition,
ZActionCalculateNumber,
ZActionCalculateText,
ZSurveyAdvancedLogic,
} from "./logic";
import {
FORBIDDEN_IDS,
findLanguageCodesForDuplicateLabels,
findQuestionsWithCyclicLogic,
isConditionsGroup,
validateCardFieldsForAllLanguages,
validateQuestionLabels,
} from "./validation";
@@ -280,22 +290,6 @@ export const ZSurveyMultipleChoiceQuestion = ZSurveyQuestionBase.extend({
shuffleOption: ZShuffleOption.optional(),
otherOptionPlaceholder: ZI18nString.optional(),
});
// .refine(
// (question) => {
// const { logic, type } = question;
// if (type === TSurveyQuestionTypeEnum.MultipleChoiceSingle) {
// // The single choice question should not have 'includesAll' logic
// return !logic?.some((l) => l.condition === "includesAll");
// }
// // The multi choice question should not have 'notEquals' logic
// return !logic?.some((l) => l.condition === "notEquals");
// },
// {
// message:
// "MultipleChoiceSingle question should not have 'includesAll' logic and MultipleChoiceMulti question should not have 'notEquals' logic",
// }
// );
export type TSurveyMultipleChoiceQuestion = z.infer<typeof ZSurveyMultipleChoiceQuestion>;
@@ -827,32 +821,13 @@ export const ZSurvey = z
}
}
// if (question.logic) {
// question.logic.forEach((logic, logicIndex) => {
// // validate condition
// // validate actions
if (question.logic) {
const logicIssues = validateLogic(survey, questionIndex, question.logic);
// // if (
// // [
// // "isSubmitted",
// // "isSkipped",
// // "isClicked",
// // "isAccepted",
// // "isBooked",
// // "isPartiallySubmitted",
// // "isCompletelySubmitted",
// // ].includes(condition.operator) &&
// // condition.rightOperand !== undefined
// // ) {
// // ctx.addIssue({
// // code: z.ZodIssueCode.custom,
// // message: `${messagePrefix}${messageField} in question ${String(questionIndex + 1)}${messageSuffix}`,
// // path: ["questions", questionIndex, field],
// // params: isDefaultOnly ? undefined : { invalidLanguageCodes },
// // })
// // }
// });
// }
logicIssues.forEach((issue) => {
ctx.addIssue(issue);
});
}
});
const questionsWithCyclicLogic = findQuestionsWithCyclicLogic(questions);
@@ -921,9 +896,391 @@ export const ZSurvey = z
});
});
// const validateActions = (actions: TAction[], ctx: z.RefinementCtx) => {
// // check if
// };
const isInvalidOperatorsForQuestionType = (
question: TSurveyQuestion,
operator: TSurveyLogicCondition
): boolean => {
let isInvalidOperator = false;
const questionType = question.type;
switch (questionType) {
case TSurveyQuestionTypeEnum.OpenText:
switch (question.inputType) {
case "email":
case "phone":
case "text":
case "url":
if (
![
"equals",
"doesNotEqual",
"contains",
"doesNotContain",
"startsWith",
"doesNotStartWith",
"endsWith",
"doesNotEndWith",
"isSubmitted",
"isSkipped",
].includes(operator)
) {
isInvalidOperator = true;
}
break;
case "number":
if (
![
"equals",
"doesNotEqual",
"isGreaterThan",
"isLessThan",
"isGreaterThanOrEqual",
"isLessThanOrEqual",
"isSubmitted",
"isSkipped",
].includes(operator)
) {
isInvalidOperator = true;
}
}
break;
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
if (!["equals", "doesNotEqual", "equalsOneOf", "isSubmitted", "isSkipped"].includes(operator)) {
isInvalidOperator = true;
}
break;
case TSurveyQuestionTypeEnum.MultipleChoiceMulti:
case TSurveyQuestionTypeEnum.PictureSelection:
if (
!["equals", "doesNotEqual", "includesAllOf", "includesOneOf", "isSubmitted", "isSkipped"].includes(
operator
)
) {
isInvalidOperator = true;
}
break;
case TSurveyQuestionTypeEnum.NPS:
case TSurveyQuestionTypeEnum.Rating:
if (
![
"equals",
"doesNotEqual",
"isGreaterThan",
"isLessThan",
"isGreaterThanOrEqual",
"isLessThanOrEqual",
"isSubmitted",
"isSkipped",
].includes(operator)
) {
isInvalidOperator = true;
}
break;
case TSurveyQuestionTypeEnum.CTA:
if (!["isClicked", "isSkipped"].includes(operator)) {
isInvalidOperator = true;
}
break;
case TSurveyQuestionTypeEnum.Consent:
if (!["isAccepted", "isSkipped"].includes(operator)) {
isInvalidOperator = true;
}
break;
case TSurveyQuestionTypeEnum.Date:
if (!["equals", "doesNotEqual", "isBefore", "isAfter", "isSubmitted", "isSkipped"].includes(operator)) {
isInvalidOperator = true;
}
break;
case TSurveyQuestionTypeEnum.FileUpload:
case TSurveyQuestionTypeEnum.Address:
if (!["isSubmitted", "isSkipped"].includes(operator)) {
isInvalidOperator = true;
}
break;
case TSurveyQuestionTypeEnum.Cal:
if (!["isBooked", "isSkipped"].includes(operator)) {
isInvalidOperator = true;
}
break;
case TSurveyQuestionTypeEnum.Matrix:
if (!["isPartiallySubmitted", "isCompletelySubmitted", "isSkipped"].includes(operator)) {
isInvalidOperator = true;
}
break;
default:
isInvalidOperator = true;
}
return isInvalidOperator;
};
const isInvalidOperatorsForVariableType = (
variableType: "text" | "number",
operator: TSurveyLogicCondition
): boolean => {
let isInvalidOperator = false;
switch (variableType) {
case "text":
if (
![
"equals",
"doesNotEqual",
"contains",
"doesNotContain",
"startsWith",
"doesNotStartWith",
"endsWith",
"doesNotEndWith",
].includes(operator)
) {
isInvalidOperator = true;
}
break;
case "number":
if (
![
"equals",
"doesNotEqual",
"isGreaterThan",
"isLessThan",
"isGreaterThanOrEqual",
"isLessThanOrEqual",
].includes(operator)
) {
isInvalidOperator = true;
}
break;
}
return isInvalidOperator;
};
const isInvalidOperatorsForHiddenFieldType = (operator: TSurveyLogicCondition): boolean => {
let isInvalidOperator = false;
if (
![
"equals",
"doesNotEqual",
"contains",
"doesNotContain",
"startsWith",
"doesNotStartWith",
"endsWith",
"doesNotEndWith",
].includes(operator)
) {
isInvalidOperator = true;
}
return isInvalidOperator;
};
const validateConditions = (
survey: TSurvey,
questionIndex: number,
logicIndex: number,
conditions: TConditionGroup
): z.ZodIssue[] => {
const issues: z.ZodIssue[] = [];
const validateSingleCondition = (condition: TSingleCondition): void => {
const { leftOperand, operator, rightOperand } = condition;
// Validate left operand
if (leftOperand.type === "question") {
const questionId = leftOperand.value;
const question = survey.questions.find((q) => q.id === questionId);
if (!question) {
issues.push({
code: z.ZodIssueCode.custom,
message: `Question ID ${questionId} does not exist in logic no: ${String(logicIndex + 1)} of question ${String(questionIndex + 1)}`,
path: ["questions", questionIndex, "logic", logicIndex, "conditions"],
});
return;
}
// Validate operator based on question type
const isInvalidOperator = isInvalidOperatorsForQuestionType(question, operator);
if (isInvalidOperator) {
issues.push({
code: z.ZodIssueCode.custom,
message: `Invalid operator "${operator}" for question type "${question.type}" in logic no: ${String(logicIndex + 1)} of question ${String(questionIndex + 1)}`,
path: ["questions", questionIndex, "logic", logicIndex, "conditions"],
});
}
// Validate right operand
if (
[
"isSubmitted",
"isSkipped",
"isClicked",
"isAccepted",
"isBooked",
"isPartiallySubmitted",
"isCompletelySubmitted",
].includes(operator) &&
rightOperand !== undefined
) {
issues.push({
code: z.ZodIssueCode.custom,
message: `Right operand should not be defined for operator "${operator}" in logic no: ${String(logicIndex + 1)} of question ${String(questionIndex + 1)}`,
path: ["questions", questionIndex, "logic", logicIndex, "conditions"],
});
}
} else if (leftOperand.type === "variable") {
const variableId = leftOperand.value;
const variable = survey.variables.find((v) => v.id === variableId);
if (!variable) {
issues.push({
code: z.ZodIssueCode.custom,
message: `Variable ID ${variableId} does not exist in logic no: ${String(logicIndex + 1)} of question ${String(questionIndex + 1)}`,
path: ["questions", questionIndex, "logic", logicIndex, "conditions"],
});
return;
}
// Validate operator based on variable type
const isInvalidOperator = isInvalidOperatorsForVariableType(variable.type, operator);
if (isInvalidOperator) {
issues.push({
code: z.ZodIssueCode.custom,
message: `Invalid operator "${operator}" for variable ${variable.name} of type "${variable.type}" in logic no: ${String(logicIndex + 1)} of question ${String(questionIndex + 1)}`,
path: ["questions", questionIndex, "logic", logicIndex, "conditions"],
});
}
} else {
const hiddenFieldId = leftOperand.value;
const hiddenField = survey.hiddenFields.fieldIds?.find((fieldId) => fieldId === hiddenFieldId);
if (!hiddenField) {
issues.push({
code: z.ZodIssueCode.custom,
message: `Hidden field ID ${hiddenFieldId} does not exist in logic no: ${String(logicIndex + 1)} of question ${String(questionIndex + 1)}`,
path: ["questions", questionIndex, "logic", logicIndex, "conditions"],
});
}
// Validate operator based on hidden field type
const isInvalidOperator = isInvalidOperatorsForHiddenFieldType(operator);
if (isInvalidOperator) {
issues.push({
code: z.ZodIssueCode.custom,
message: `Invalid operator "${operator}" for hidden field in logic no: ${String(logicIndex + 1)} of question ${String(questionIndex + 1)}`,
path: ["questions", questionIndex, "logic", logicIndex, "conditions"],
});
}
}
};
const validateConditionGroup = (group: TConditionGroup): void => {
group.conditions.forEach((condition) => {
if (isConditionsGroup(condition)) {
validateConditionGroup(condition);
} else {
validateSingleCondition(condition);
}
});
};
validateConditionGroup(conditions);
return issues;
};
const validateActions = (
survey: TSurvey,
questionIndex: number,
logicIndex: number,
actions: TAction[]
): z.ZodIssue[] => {
const questionIds = survey.questions.map((q) => q.id);
const actionIssues: (z.ZodIssue | undefined)[] = actions.map((action) => {
if (action.objective === "calculate") {
const variable = survey.variables.find((v) => v.id === action.variableId);
if (!variable) {
return {
code: z.ZodIssueCode.custom,
message: `Variable ID ${action.variableId} does not exist in logic no: ${String(logicIndex + 1)} of question ${String(questionIndex + 1)}`,
path: ["questions", questionIndex, "logic", logicIndex],
};
}
if (variable.type === "text") {
const textVariableParseData = ZActionCalculateText.safeParse(action);
if (!textVariableParseData.success) {
return {
code: z.ZodIssueCode.custom,
message: textVariableParseData.error.errors[0].message,
path: ["questions", questionIndex, "logic", logicIndex],
};
}
}
const numberVariableParseData = ZActionCalculateNumber.safeParse(action);
if (!numberVariableParseData.success) {
return {
code: z.ZodIssueCode.custom,
message: numberVariableParseData.error.errors[0].message,
path: ["questions", questionIndex, "logic", logicIndex],
};
}
} else {
const endingIds = survey.endings.map((ending) => ending.id);
const possibleQuestionIds =
action.objective === "jumpToQuestion" ? [...questionIds, ...endingIds] : questionIds;
if (!possibleQuestionIds.includes(action.target)) {
return {
code: z.ZodIssueCode.custom,
message: `Question ID ${action.target} does not exist in logic no: ${String(logicIndex + 1)} of question ${String(questionIndex + 1)}`,
path: ["questions", questionIndex, "logic"],
};
}
if (action.objective === "requireAnswer") {
const requiredQuestionIds = survey.questions
.filter((question) => question.required)
.map((question) => question.id);
if (!requiredQuestionIds.includes(action.target)) {
const quesIdx = survey.questions.findIndex((q) => q.id === action.target);
return {
code: z.ZodIssueCode.custom,
message: `Question ${String(quesIdx + 1)} is already required in logic no: ${String(logicIndex + 1)} of question ${String(questionIndex + 1)}`,
path: ["questions", questionIndex, "logic", logicIndex],
};
}
}
}
return undefined;
});
return actionIssues.filter((issue) => issue !== undefined);
};
const validateLogic = (
survey: TSurvey,
questionIndex: number,
logic: TSurveyAdvancedLogic[]
): z.ZodIssue[] => {
const logicIssues = logic.map((logicItem, logicIndex) => {
return [
...validateConditions(survey, questionIndex, logicIndex, logicItem.conditions),
...validateActions(survey, questionIndex, logicIndex, logicItem.actions),
];
});
return logicIssues.flat();
};
// ZSurvey is a refinement, so to extend it to ZSurveyUpdateInput, we need to transform the innerType and then apply the same refinements.
export const ZSurveyUpdateInput = ZSurvey.innerType()

View File

@@ -1,5 +1,5 @@
import { z } from "zod";
import type { TAction, TActionJumpToQuestion } from "./logic";
import type { TAction, TActionJumpToQuestion, TConditionGroup, TSingleCondition } from "./logic";
import type { TI18nString, TSurveyLanguage, TSurveyQuestion } from "./types";
export const FORBIDDEN_IDS = [
@@ -219,3 +219,9 @@ export const validateId = (
return null;
};
type TCondition = TSingleCondition | TConditionGroup;
export const isConditionsGroup = (condition: TCondition): condition is TConditionGroup => {
return "conditions" in condition;
};

View File

@@ -33,7 +33,7 @@ interface InputComboboxProps {
options?: TComboboxOption[];
groupedOptions?: TComboboxGroupedOption[];
value?: string | number | string[] | null;
onChangeValue: (value: string | number | string[], option?: TComboboxOption) => void;
onChangeValue: (value: string | number | string[], option?: TComboboxOption, fromInput?: boolean) => void;
inputProps?: Omit<React.ComponentProps<typeof Input>, "value" | "onChange">;
clearable?: boolean;
withInput?: boolean;
@@ -92,6 +92,9 @@ export const InputCombobox = ({
if (inputType !== "input") {
setInputType("input");
}
} else {
setLocalValue(null);
setInputType(null);
}
}
}
@@ -128,7 +131,9 @@ export const InputCombobox = ({
};
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const inputType = e.target.type;
const value = e.target.value;
if (value === "") {
setLocalValue(null);
onChangeValue("");
@@ -138,8 +143,10 @@ export const InputCombobox = ({
setInputType("input");
}
setLocalValue(value);
onChangeValue(value);
const val = inputType === "number" ? Number(value) : value;
setLocalValue(val);
onChangeValue(val, undefined, true);
};
const getDisplayValue = useMemo(() => {