diff --git a/apps/web/app/lib/templates.ts b/apps/web/app/lib/templates.ts index 1c78c4afc2..cfbc4c241b 100644 --- a/apps/web/app/lib/templates.ts +++ b/apps/web/app/lib/templates.ts @@ -3668,6 +3668,7 @@ export const previewSurvey = (projectName: string, t: TFunction) => { isDraft: true, }, ], + blocks: [], endings: [ { id: "cltyqp5ng000108l9dmxw6nde", diff --git a/apps/web/lib/survey/__mock__/survey.mock.ts b/apps/web/lib/survey/__mock__/survey.mock.ts index 6eace0f326..68fc596222 100644 --- a/apps/web/lib/survey/__mock__/survey.mock.ts +++ b/apps/web/lib/survey/__mock__/survey.mock.ts @@ -261,6 +261,7 @@ export const mockSyncSurveyOutput: SurveyMock = { variables: [], showLanguageSwitch: null, metadata: {}, + blocks: [], }; export const mockSurveyOutput: SurveyMock = { @@ -282,6 +283,7 @@ export const mockSurveyOutput: SurveyMock = { languages: mockSurveyLanguages, followUps: [], variables: [], + blocks: [], showLanguageSwitch: null, ...baseSurveyProperties, }; @@ -311,6 +313,7 @@ export const updateSurveyInput: TSurvey = { variables: [], followUps: [], metadata: {}, + blocks: [], ...commonMockProperties, ...baseSurveyProperties, }; diff --git a/apps/web/lib/survey/service.ts b/apps/web/lib/survey/service.ts index 6f3a801b07..dd90918e6b 100644 --- a/apps/web/lib/survey/service.ts +++ b/apps/web/lib/survey/service.ts @@ -37,6 +37,7 @@ export const selectSurvey = { status: true, welcomeCard: true, questions: true, + blocks: true, endings: true, hiddenFields: true, variables: true, diff --git a/apps/web/modules/api/v2/management/surveys/types/surveys.ts b/apps/web/modules/api/v2/management/surveys/types/surveys.ts index c491a4993f..aa3f96bd5d 100644 --- a/apps/web/modules/api/v2/management/surveys/types/surveys.ts +++ b/apps/web/modules/api/v2/management/surveys/types/surveys.ts @@ -33,6 +33,7 @@ export const ZSurveyInput = ZSurveyWithoutQuestionType.pick({ type: true, environmentId: true, questions: true, + blocks: true, endings: true, hiddenFields: true, variables: true, diff --git a/apps/web/modules/survey/lib/survey.ts b/apps/web/modules/survey/lib/survey.ts index 1a8e214ee7..56a955cab5 100644 --- a/apps/web/modules/survey/lib/survey.ts +++ b/apps/web/modules/survey/lib/survey.ts @@ -16,6 +16,7 @@ export const selectSurvey = { status: true, welcomeCard: true, questions: true, + blocks: true, endings: true, hiddenFields: true, variables: true, diff --git a/apps/web/modules/survey/link/lib/data.ts b/apps/web/modules/survey/link/lib/data.ts index 31f1e7c332..ec5f7f6446 100644 --- a/apps/web/modules/survey/link/lib/data.ts +++ b/apps/web/modules/survey/link/lib/data.ts @@ -30,6 +30,7 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => { // Survey configuration welcomeCard: true, questions: true, + blocks: true, endings: true, hiddenFields: true, variables: true, diff --git a/apps/web/modules/survey/list/lib/survey.ts b/apps/web/modules/survey/list/lib/survey.ts index a8c134eeb0..72ed5a3cd8 100644 --- a/apps/web/modules/survey/list/lib/survey.ts +++ b/apps/web/modules/survey/list/lib/survey.ts @@ -249,6 +249,7 @@ const getExistingSurvey = async (surveyId: string) => { }, welcomeCard: true, questions: true, + blocks: true, endings: true, variables: true, hiddenFields: true, diff --git a/apps/web/modules/survey/templates/lib/minimal-survey.ts b/apps/web/modules/survey/templates/lib/minimal-survey.ts index 2e1795c34e..4ca61add4d 100644 --- a/apps/web/modules/survey/templates/lib/minimal-survey.ts +++ b/apps/web/modules/survey/templates/lib/minimal-survey.ts @@ -18,6 +18,7 @@ export const getMinimalSurvey = (t: TFunction): TSurvey => ({ displayLimit: null, welcomeCard: getDefaultWelcomeCard(t), questions: [], + blocks: [], endings: [getDefaultEndingCard([], t)], hiddenFields: { enabled: false, diff --git a/packages/database/json-types.ts b/packages/database/json-types.ts index 4e097ce7e4..1d2b22907d 100644 --- a/packages/database/json-types.ts +++ b/packages/database/json-types.ts @@ -8,6 +8,7 @@ import { type TProjectConfig, type TProjectStyling } from "../types/project"; import type { TSurveyQuotaLogic } from "../types/quota"; import { type TResponseContactAttributes, type TResponseData, type TResponseMeta } from "../types/responses"; import { type TBaseFilters } from "../types/segment"; +import { type TSurveyBlocks } from "../types/surveys/blocks"; import { type TSurveyClosedMessage, type TSurveyEnding, @@ -35,6 +36,7 @@ declare global { export type ResponseContactAttributes = TResponseContactAttributes; export type SurveyWelcomeCard = TSurveyWelcomeCard; export type SurveyQuestions = TSurveyQuestions; + export type SurveyBlocks = TSurveyBlocks; export type SurveyEnding = TSurveyEnding; export type SurveyHiddenFields = TSurveyHiddenFields; export type SurveyVariables = TSurveyVariables; diff --git a/packages/database/migration/20251029165242_add_blocks_to_survey/migration.sql b/packages/database/migration/20251029165242_add_blocks_to_survey/migration.sql new file mode 100644 index 0000000000..0a137ff1ef --- /dev/null +++ b/packages/database/migration/20251029165242_add_blocks_to_survey/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."Survey" ADD COLUMN "blocks" JSONB[] DEFAULT ARRAY[]::JSONB[]; diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma index 84b3e42107..fa4863d1ff 100644 --- a/packages/database/schema.prisma +++ b/packages/database/schema.prisma @@ -340,6 +340,8 @@ model Survey { welcomeCard Json @default("{\"enabled\": false}") /// [SurveyQuestions] questions Json @default("[]") + /// [SurveyBlocks] + blocks Json[] @default([]) /// [SurveyEnding] endings Json[] @default([]) /// [SurveyHiddenFields] diff --git a/packages/database/zod/surveys.ts b/packages/database/zod/surveys.ts index 559a4137ba..a9d04342b5 100644 --- a/packages/database/zod/surveys.ts +++ b/packages/database/zod/surveys.ts @@ -1,7 +1,8 @@ +/* eslint-disable import/no-relative-packages -- Need to import from parent package */ import { SurveyStatus, SurveyType } from "@prisma/client"; import { z } from "zod"; import { extendZodWithOpenApi } from "zod-openapi"; -// eslint-disable-next-line import/no-relative-packages -- Need to import from parent package +import { ZSurveyBlocks } from "../../types/surveys/blocks"; import { ZSurveyEnding, ZSurveyMetadata, @@ -96,6 +97,9 @@ const ZSurveyBase = z.object({ questions: z.array(ZSurveyQuestion).openapi({ description: "The questions of the survey", }), + blocks: ZSurveyBlocks.default([]).openapi({ + description: "The blocks of the survey", + }), endings: z.array(ZSurveyEnding).default([]).openapi({ description: "The endings of the survey", }), diff --git a/packages/types/surveys/blocks-validation.ts b/packages/types/surveys/blocks-validation.ts new file mode 100644 index 0000000000..059d4bfb78 --- /dev/null +++ b/packages/types/surveys/blocks-validation.ts @@ -0,0 +1,64 @@ +import type { TActionJumpToBlock, TSurveyBlock, TSurveyBlockId, TSurveyBlockLogicAction } from "./blocks"; + +export const findBlocksWithCyclicLogic = (blocks: TSurveyBlock[]): TSurveyBlockId[] => { + const visited: Record = {}; + const recStack: Record = {}; + const cyclicBlocks = new Set(); + + const checkForCyclicLogic = (blockId: TSurveyBlockId): boolean => { + if (!visited[blockId]) { + visited[blockId] = true; + recStack[blockId] = true; + + const block = blocks.find((b) => b.id === blockId); + if (block?.logic && block.logic.length > 0) { + for (const logic of block.logic) { + const jumpActions = findJumpToBlockActions(logic.actions); + for (const jumpAction of jumpActions) { + const destination = jumpAction.target; + if (!visited[destination] && checkForCyclicLogic(destination)) { + cyclicBlocks.add(blockId); + return true; + } else if (recStack[destination]) { + cyclicBlocks.add(blockId); + return true; + } + } + } + } + + // Check fallback logic + if (block?.logicFallback) { + const fallbackBlockId = block.logicFallback; + if (!visited[fallbackBlockId] && checkForCyclicLogic(fallbackBlockId)) { + cyclicBlocks.add(blockId); + return true; + } else if (recStack[fallbackBlockId]) { + cyclicBlocks.add(blockId); + return true; + } + } + + // Handle default behavior: move to the next block if no jump actions or fallback logic is defined + const nextBlockIndex = blocks.findIndex((b) => b.id === blockId) + 1; + const nextBlock = blocks[nextBlockIndex] as TSurveyBlock | undefined; + if (nextBlock && !visited[nextBlock.id] && checkForCyclicLogic(nextBlock.id)) { + return true; + } + } + + recStack[blockId] = false; + return false; + }; + + for (const block of blocks) { + checkForCyclicLogic(block.id); + } + + return Array.from(cyclicBlocks); +}; + +// Helper function to find all "jumpToBlock" actions in the logic +const findJumpToBlockActions = (actions: TSurveyBlockLogicAction[]): TActionJumpToBlock[] => { + return actions.filter((action): action is TActionJumpToBlock => action.objective === "jumpToBlock"); +}; diff --git a/packages/types/surveys/blocks.ts b/packages/types/surveys/blocks.ts new file mode 100644 index 0000000000..2af0a86e38 --- /dev/null +++ b/packages/types/surveys/blocks.ts @@ -0,0 +1,232 @@ +/* eslint-disable import/no-cycle -- Required for circular dependency between types, blocks, and elements */ +import { z } from "zod"; +import { ZId } from "../common"; +import { ZSurveyElementId, ZSurveyElements } from "./elements"; +import type { TSurveyLogicConditionsOperator } from "./types"; +import { + ZActionNumberVariableCalculateOperator, + ZActionTextVariableCalculateOperator, + ZConnector, + ZI18nString, + ZSurveyLogicConditionsOperator, +} from "./types"; + +// Block ID - CUID (system-generated, NOT user-editable) +export const ZSurveyBlockId = z.string().cuid2(); + +export type TSurveyBlockId = z.infer; + +// Copy condition types from types.ts for block logic +const ZDynamicQuestion = z.object({ + type: z.literal("question"), + value: z.string().min(1, "Conditional Logic: Question id cannot be empty"), + meta: z.record(z.string()).optional(), +}); + +const ZDynamicVariable = z.object({ + type: z.literal("variable"), + value: z + .string() + .cuid2({ message: "Conditional Logic: Variable id must be a valid cuid" }) + .min(1, "Conditional Logic: Variable id cannot be empty"), +}); + +const ZDynamicHiddenField = z.object({ + type: z.literal("hiddenField"), + value: z.string().min(1, "Conditional Logic: Hidden field id cannot be empty"), +}); + +const ZDynamicLogicFieldValue = z.union([ZDynamicQuestion, ZDynamicVariable, ZDynamicHiddenField], { + message: "Conditional Logic: Invalid dynamic field value", +}); + +const ZLeftOperand = ZDynamicLogicFieldValue; +export type TLeftOperand = z.infer; + +export const ZRightOperandStatic = z.object({ + type: z.literal("static"), + value: z.union([z.string(), z.number(), z.array(z.string())]), +}); + +export const ZRightOperand = z.union([ZRightOperandStatic, ZDynamicLogicFieldValue]); +export type TRightOperand = z.infer; + +const operatorsWithoutRightOperand: readonly TSurveyLogicConditionsOperator[] = [ + ZSurveyLogicConditionsOperator.Enum.isSubmitted, + ZSurveyLogicConditionsOperator.Enum.isSkipped, + ZSurveyLogicConditionsOperator.Enum.isClicked, + ZSurveyLogicConditionsOperator.Enum.isAccepted, + ZSurveyLogicConditionsOperator.Enum.isBooked, + ZSurveyLogicConditionsOperator.Enum.isPartiallySubmitted, + ZSurveyLogicConditionsOperator.Enum.isCompletelySubmitted, + ZSurveyLogicConditionsOperator.Enum.isSet, + ZSurveyLogicConditionsOperator.Enum.isNotSet, + ZSurveyLogicConditionsOperator.Enum.isEmpty, + ZSurveyLogicConditionsOperator.Enum.isNotEmpty, +]; + +export const ZSingleCondition = z + .object({ + id: ZId, + leftOperand: ZLeftOperand, + operator: ZSurveyLogicConditionsOperator, + rightOperand: ZRightOperand.optional(), + }) + .and( + z.object({ + connector: z.undefined(), + }) + ) + .superRefine((val, ctx) => { + if (!operatorsWithoutRightOperand.includes(val.operator)) { + if (val.rightOperand === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + + message: `Conditional Logic: right operand is required for operator "${val.operator}"`, + path: ["rightOperand"], + }); + } else if (val.rightOperand.type === "static" && val.rightOperand.value === "") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + + message: `Conditional Logic: right operand value cannot be empty for operator "${val.operator}"`, + }); + } + } else if (val.rightOperand !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + + message: `Conditional Logic: right operand should not be present for operator "${val.operator}"`, + path: ["rightOperand"], + }); + } + }); + +export type TSingleCondition = z.infer; + +export interface TConditionGroup { + id: string; + connector: z.infer; + conditions: (TSingleCondition | TConditionGroup)[]; +} + +const ZConditionGroup: z.ZodType = z.lazy(() => + z.object({ + id: ZId, + connector: ZConnector, + conditions: z.array(z.union([ZSingleCondition, ZConditionGroup])), + }) +); + +// Block Logic - Actions +const ZActionCalculateBase = z.object({ + id: ZId, + objective: z.literal("calculate"), + variableId: z.string(), +}); + +export const ZActionCalculateText = ZActionCalculateBase.extend({ + operator: ZActionTextVariableCalculateOperator, + value: z.union([ + z.object({ + type: z.literal("static"), + value: z + .string({ message: "Conditional Logic: Value must be a string for text variable" }) + .min(1, "Conditional Logic: Please enter a value in logic field"), + }), + ZDynamicLogicFieldValue, + ]), +}); + +export const ZActionCalculateNumber = ZActionCalculateBase.extend({ + operator: ZActionNumberVariableCalculateOperator, + value: z.union([ + z.object({ + type: z.literal("static"), + value: z.number({ message: "Conditional Logic: Value must be a number for number variable" }), + }), + ZDynamicLogicFieldValue, + ]), +}).superRefine((val, ctx) => { + if (val.operator === "divide" && val.value.type === "static" && val.value.value === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Conditional Logic: Cannot divide by zero", + path: ["value", "value"], + }); + } +}); + +export const ZActionCalculate = z.union([ZActionCalculateText, ZActionCalculateNumber]); + +export type TActionCalculate = z.infer; + +// RequireAnswer action - targets element IDs + +export const ZActionRequireAnswer = z.object({ + id: ZId, + objective: z.literal("requireAnswer"), + target: ZSurveyElementId, // Targets elements, validated to be outside current block +}); + +export type TActionRequireAnswer = z.infer; + +// JumpToBlock action - targets block IDs (CUIDs) + +export const ZActionJumpToBlock = z.object({ + id: ZId, + objective: z.literal("jumpToBlock"), + target: ZSurveyBlockId, // Must be a valid CUID +}); + +export type TActionJumpToBlock = z.infer; + +// Block logic actions + +export const ZSurveyBlockLogicAction = z.union([ZActionCalculate, ZActionRequireAnswer, ZActionJumpToBlock]); + +export type TSurveyBlockLogicAction = z.infer; + +const ZSurveyBlockLogicActions = z.array(ZSurveyBlockLogicAction); +export type TSurveyBlockLogicActions = z.infer; + +// Block Logic + +export const ZSurveyBlockLogic = z.object({ + id: ZId, + conditions: ZConditionGroup, + actions: ZSurveyBlockLogicActions, +}); + +export type TSurveyBlockLogic = z.infer; + +// Block definition +export const ZSurveyBlock = z + .object({ + id: ZSurveyBlockId, // CUID + name: z.string().min(1, { message: "Block name is required" }), // REQUIRED for editor + elements: ZSurveyElements.min(1, { message: "Block must have at least one element" }), + logic: z.array(ZSurveyBlockLogic).optional(), + logicFallback: ZSurveyBlockId.optional(), + buttonLabel: ZI18nString.optional(), + backButtonLabel: ZI18nString.optional(), + isDraft: z.boolean().optional(), + }) + .superRefine((block, ctx) => { + // Validate element IDs are unique within block + const elementIds = block.elements.map((e) => e.id); + const uniqueElementIds = new Set(elementIds); + if (uniqueElementIds.size !== elementIds.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Element IDs must be unique within a block", + path: [elementIds.findIndex((id, index) => elementIds.indexOf(id) !== index), "id"], + }); + } + }); + +export type TSurveyBlock = z.infer; + +export const ZSurveyBlocks = z.array(ZSurveyBlock); +export type TSurveyBlocks = z.infer; diff --git a/packages/types/surveys/elements-validation.ts b/packages/types/surveys/elements-validation.ts new file mode 100644 index 0000000000..671f088d79 --- /dev/null +++ b/packages/types/surveys/elements-validation.ts @@ -0,0 +1,114 @@ +import { z } from "zod"; +import type { TI18nString, TSurveyLanguage } from "./types"; +import { getTextContent } from "./validation"; + +const extractLanguageCodes = (surveyLanguages?: TSurveyLanguage[]): string[] => { + if (!surveyLanguages) return []; + return surveyLanguages.map((surveyLanguage) => + surveyLanguage.default ? "default" : surveyLanguage.language.code + ); +}; + +const validateLabelForAllLanguages = (label: TI18nString, surveyLanguages: TSurveyLanguage[]): string[] => { + const enabledLanguages = surveyLanguages.filter((lang) => lang.enabled); + const languageCodes = extractLanguageCodes(enabledLanguages); + + const languages = !languageCodes.length ? ["default"] : languageCodes; + const invalidLanguageCodes = languages.filter((language) => { + // Check if label exists and is not undefined + if (!label[language]) return true; + + // Use getTextContent to extract text from HTML or plain text + const textContent = getTextContent(label[language]); + return textContent.length === 0; + }); + + return invalidLanguageCodes.map((invalidLanguageCode) => { + if (invalidLanguageCode === "default") { + return surveyLanguages.find((lang) => lang.default)?.language.code ?? "default"; + } + + return invalidLanguageCode; + }); +}; + +// Map for element field names to user-friendly labels +const ELEMENT_FIELD_TO_LABEL_MAP: Record = { + headline: "question", + subheader: "description", + placeholder: "placeholder", + upperLabel: "upper label", + lowerLabel: "lower label", + "consent.label": "checkbox label", + dismissButtonLabel: "dismiss button label", + html: "description", +}; + +export const validateElementLabels = ( + field: string, + fieldLabel: TI18nString, + languages: TSurveyLanguage[], + blockIndex: number, + elementIndex: number, + skipArticle = false +): z.IssueData | null => { + // fieldLabel should contain all the keys present in languages + for (const language of languages) { + if ( + !language.default && + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- could be undefined + fieldLabel[language.language.code] === undefined + ) { + return { + code: z.ZodIssueCode.custom, + message: `The ${field} in element ${String(elementIndex + 1)} of block ${String(blockIndex + 1)} is not present for the following languages: ${language.language.code}`, + path: ["blocks", blockIndex, "elements", elementIndex, field], + }; + } + } + + const invalidLanguageCodes = validateLabelForAllLanguages(fieldLabel, languages); + const isDefaultOnly = invalidLanguageCodes.length === 1 && invalidLanguageCodes[0] === "default"; + + const messagePrefix = skipArticle ? "" : "The "; + const messageField = ELEMENT_FIELD_TO_LABEL_MAP[field] ? ELEMENT_FIELD_TO_LABEL_MAP[field] : field; + const messageSuffix = isDefaultOnly ? " is missing" : " is missing for the following languages: "; + + const message = isDefaultOnly + ? `${messagePrefix}${messageField} in element ${String(elementIndex + 1)} of block ${String(blockIndex + 1)}${messageSuffix}` + : `${messagePrefix}${messageField} in element ${String(elementIndex + 1)} of block ${String(blockIndex + 1)}${messageSuffix} -fLang- ${invalidLanguageCodes.join()}`; + + if (invalidLanguageCodes.length) { + return { + code: z.ZodIssueCode.custom, + message, + path: ["blocks", blockIndex, "elements", elementIndex, field], + params: isDefaultOnly ? undefined : { invalidLanguageCodes }, + }; + } + + return null; +}; + +export const findLanguageCodesForDuplicateLabels = ( + labels: TI18nString[], + surveyLanguages: TSurveyLanguage[] +): string[] => { + const enabledLanguages = surveyLanguages.filter((lang) => lang.enabled); + const languageCodes = extractLanguageCodes(enabledLanguages); + + const languagesToCheck = languageCodes.length === 0 ? ["default"] : languageCodes; + + const duplicateLabels = new Set(); + + for (const language of languagesToCheck) { + const labelTexts = labels.map((label) => label[language].trim()).filter(Boolean); + const uniqueLabels = new Set(labelTexts); + + if (uniqueLabels.size !== labelTexts.length) { + duplicateLabels.add(language); + } + } + + return Array.from(duplicateLabels); +}; diff --git a/packages/types/surveys/elements.ts b/packages/types/surveys/elements.ts new file mode 100644 index 0000000000..17623739c1 --- /dev/null +++ b/packages/types/surveys/elements.ts @@ -0,0 +1,308 @@ +/* eslint-disable import/no-cycle -- Required for circular dependency between types, blocks, and elements */ +import { z } from "zod"; +import { ZAllowedFileExtension } from "../storage"; +import { ZI18nString } from "./types"; +import { FORBIDDEN_IDS } from "./validation"; + +// Element Type Enum (same as question types) +export enum TSurveyElementTypeEnum { + 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", + Ranking = "ranking", + ContactInfo = "contactInfo", +} + +// Element ID validation (same rules as questions - USER EDITABLE) +export const ZSurveyElementId = z.string().superRefine((id, ctx) => { + if (FORBIDDEN_IDS.includes(id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Element id is not allowed`, + }); + } + + if (id.includes(" ")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Element id not allowed, avoid using spaces.", + }); + } + + if (!/^[a-zA-Z0-9_-]+$/.test(id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Element id not allowed, use only alphanumeric characters, hyphens, or underscores.", + }); + } +}); + +export type TSurveyElementId = z.infer; + +// Base element (like ZSurveyQuestionBase but WITHOUT logic, buttonLabel, backButtonLabel) +export const ZSurveyElementBase = z.object({ + id: ZSurveyElementId, + type: z.string(), + headline: ZI18nString, + subheader: ZI18nString.optional(), + imageUrl: z.string().optional(), + videoUrl: z.string().optional(), + required: z.boolean(), + scale: z.enum(["number", "smiley", "star"]).optional(), + range: z.union([z.literal(5), z.literal(3), z.literal(4), z.literal(7), z.literal(10)]).optional(), + isDraft: z.boolean().optional(), +}); + +// OpenText Element +export const ZSurveyOpenTextElementInputType = z.enum(["text", "email", "url", "number", "phone"]); +export type TSurveyOpenTextElementInputType = z.infer; + +export const ZSurveyOpenTextElement = ZSurveyElementBase.extend({ + type: z.literal(TSurveyElementTypeEnum.OpenText), + placeholder: ZI18nString.optional(), + longAnswer: z.boolean().optional(), + inputType: ZSurveyOpenTextElementInputType.optional().default("text"), + insightsEnabled: z.boolean().default(false).optional(), + charLimit: z + .object({ + enabled: z.boolean().default(false).optional(), + min: z.number().optional(), + max: z.number().optional(), + }) + .default({ enabled: false }), +}).superRefine((data, ctx) => { + if (data.charLimit.enabled && data.charLimit.min === undefined && data.charLimit.max === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Enter the values for either minimum or maximum field", + }); + } + + if ( + (data.charLimit.min !== undefined && data.charLimit.min < 0) || + (data.charLimit.max !== undefined && data.charLimit.max < 0) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "The character limit values should be positive", + }); + } + + if ( + data.charLimit.min !== undefined && + data.charLimit.max !== undefined && + data.charLimit.min > data.charLimit.max + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Minimum value cannot be greater than the maximum value", + }); + } +}); + +export type TSurveyOpenTextElement = z.infer; + +// Consent Element +export const ZSurveyConsentElement = ZSurveyElementBase.extend({ + type: z.literal(TSurveyElementTypeEnum.Consent), + label: ZI18nString, +}); + +export type TSurveyConsentElement = z.infer; + +// Multiple Choice Elements +export const ZSurveyElementChoice = z.object({ + id: z.string(), + label: ZI18nString, +}); + +export const ZShuffleOption = z.enum(["none", "all", "exceptLast"]); +export type TShuffleOption = z.infer; + +export const ZSurveyMultipleChoiceElement = ZSurveyElementBase.extend({ + type: z.union([ + z.literal(TSurveyElementTypeEnum.MultipleChoiceSingle), + z.literal(TSurveyElementTypeEnum.MultipleChoiceMulti), + ]), + choices: z + .array(ZSurveyElementChoice) + .min(2, { message: "Multiple Choice Element must have at least two choices" }), + shuffleOption: ZShuffleOption.optional(), + otherOptionPlaceholder: ZI18nString.optional(), +}); + +export type TSurveyMultipleChoiceElement = z.infer; + +// NPS Element +export const ZSurveyNPSElement = ZSurveyElementBase.extend({ + type: z.literal(TSurveyElementTypeEnum.NPS), + lowerLabel: ZI18nString.optional(), + upperLabel: ZI18nString.optional(), + isColorCodingEnabled: z.boolean().optional().default(false), +}); + +export type TSurveyNPSElement = z.infer; + +// CTA Element +export const ZSurveyCTAElement = ZSurveyElementBase.extend({ + type: z.literal(TSurveyElementTypeEnum.CTA), + buttonUrl: z.string().optional(), + buttonExternal: z.boolean(), + dismissButtonLabel: ZI18nString.optional(), +}); + +export type TSurveyCTAElement = z.infer; + +// Rating Element +export const ZSurveyRatingElement = ZSurveyElementBase.extend({ + type: z.literal(TSurveyElementTypeEnum.Rating), + scale: z.enum(["number", "smiley", "star"]), + range: z.union([z.literal(5), z.literal(3), z.literal(4), z.literal(6), z.literal(7), z.literal(10)]), + lowerLabel: ZI18nString.optional(), + upperLabel: ZI18nString.optional(), + isColorCodingEnabled: z.boolean().optional().default(false), +}); + +export type TSurveyRatingElement = z.infer; + +// Picture Selection Element +export const ZSurveyPictureChoice = z.object({ + id: z.string(), + imageUrl: z.string(), +}); + +export type TSurveyPictureChoice = z.infer; + +export const ZSurveyPictureSelectionElement = ZSurveyElementBase.extend({ + type: z.literal(TSurveyElementTypeEnum.PictureSelection), + allowMulti: z.boolean().optional().default(false), + choices: z + .array(ZSurveyPictureChoice) + .min(2, { message: "Picture Selection element must have atleast 2 choices" }), +}); + +export type TSurveyPictureSelectionElement = z.infer; + +// Date Element +export const ZSurveyDateElement = ZSurveyElementBase.extend({ + type: z.literal(TSurveyElementTypeEnum.Date), + html: ZI18nString.optional(), + format: z.enum(["M-d-y", "d-M-y", "y-M-d"]), +}); + +export type TSurveyDateElement = z.infer; + +// File Upload Element +export const ZSurveyFileUploadElement = ZSurveyElementBase.extend({ + type: z.literal(TSurveyElementTypeEnum.FileUpload), + allowMultipleFiles: z.boolean(), + maxSizeInMB: z.number().optional(), + allowedFileExtensions: z.array(ZAllowedFileExtension).optional(), +}); + +export type TSurveyFileUploadElement = z.infer; + +// Cal Element +export const ZSurveyCalElement = ZSurveyElementBase.extend({ + type: z.literal(TSurveyElementTypeEnum.Cal), + calUserName: z.string().min(1, { message: "Cal user name is required" }), + calHost: z.string().optional(), +}); + +export type TSurveyCalElement = z.infer; + +// Matrix Element +export const ZSurveyMatrixElementChoice = z.object({ + id: z.string(), + label: ZI18nString, +}); + +export type TSurveyMatrixElementChoice = z.infer; + +export const ZSurveyMatrixElement = ZSurveyElementBase.extend({ + type: z.literal(TSurveyElementTypeEnum.Matrix), + rows: z.array(ZSurveyMatrixElementChoice), + columns: z.array(ZSurveyMatrixElementChoice), + shuffleOption: ZShuffleOption.optional().default("none"), +}); + +export type TSurveyMatrixElement = z.infer; + +// Address Element +const ZToggleInputConfig = z.object({ + show: z.boolean(), + required: z.boolean(), + placeholder: ZI18nString, +}); + +export type TInputFieldConfig = z.infer; + +export const ZSurveyAddressElement = ZSurveyElementBase.extend({ + type: z.literal(TSurveyElementTypeEnum.Address), + addressLine1: ZToggleInputConfig, + addressLine2: ZToggleInputConfig, + city: ZToggleInputConfig, + state: ZToggleInputConfig, + zip: ZToggleInputConfig, + country: ZToggleInputConfig, +}); + +export type TSurveyAddressElement = z.infer; + +// Ranking Element +export const ZSurveyRankingElement = ZSurveyElementBase.extend({ + type: z.literal(TSurveyElementTypeEnum.Ranking), + choices: z + .array(ZSurveyElementChoice) + .min(2, { message: "Ranking Element must have at least two options" }) + .max(25, { message: "Ranking Element can have at most 25 options" }), + otherOptionPlaceholder: ZI18nString.optional(), + shuffleOption: ZShuffleOption.optional(), +}); + +export type TSurveyRankingElement = z.infer; + +// Contact Info Element +export const ZSurveyContactInfoElement = ZSurveyElementBase.extend({ + type: z.literal(TSurveyElementTypeEnum.ContactInfo), + firstName: ZToggleInputConfig, + lastName: ZToggleInputConfig, + email: ZToggleInputConfig, + phone: ZToggleInputConfig, + company: ZToggleInputConfig, +}); + +export type TSurveyContactInfoElement = z.infer; + +// Union of all element types +export const ZSurveyElement = z.union([ + ZSurveyOpenTextElement, + ZSurveyConsentElement, + ZSurveyMultipleChoiceElement, + ZSurveyNPSElement, + ZSurveyCTAElement, + ZSurveyRatingElement, + ZSurveyPictureSelectionElement, + ZSurveyDateElement, + ZSurveyFileUploadElement, + ZSurveyCalElement, + ZSurveyMatrixElement, + ZSurveyAddressElement, + ZSurveyRankingElement, + ZSurveyContactInfoElement, +]); + +export type TSurveyElement = z.infer; + +export const ZSurveyElements = z.array(ZSurveyElement); +export type TSurveyElements = z.infer; diff --git a/packages/types/surveys/types.ts b/packages/types/surveys/types.ts index 8313fcdc51..413cd97cdd 100644 --- a/packages/types/surveys/types.ts +++ b/packages/types/surveys/types.ts @@ -1,3 +1,4 @@ +/* eslint-disable import/no-cycle -- Required for circular dependency between types, blocks, and elements */ import { type ZodIssue, z } from "zod"; import { ZSurveyFollowUp } from "@formbricks/database/types/survey-follow-up"; import { ZActionClass, ZActionClassNoCodeConfig } from "../action-classes"; @@ -7,10 +8,15 @@ import { ZLanguage } from "../project"; import { ZSegment } from "../segment"; import { ZAllowedFileExtension } from "../storage"; import { ZBaseStyling } from "../styling"; +import { type TSurveyBlock, type TSurveyBlockLogicAction, ZSurveyBlocks } from "./blocks"; +import { findBlocksWithCyclicLogic } from "./blocks-validation"; +import { type TSurveyElement, TSurveyElementTypeEnum } from "./elements"; +import { validateElementLabels } from "./elements-validation"; import { FORBIDDEN_IDS, findLanguageCodesForDuplicateLabels, findQuestionsWithCyclicLogic, + getTextContent, isConditionGroup, validateCardFieldsForAllLanguages, validateQuestionLabels, @@ -811,6 +817,7 @@ export const ZSurvey = z }); } }), + blocks: ZSurveyBlocks.default([]), endings: ZSurveyEndings.superRefine((endings, ctx) => { const endingIds = endings.map((q) => q.id); const uniqueEndingIds = new Set(endingIds); @@ -869,7 +876,27 @@ export const ZSurvey = z metadata: ZSurveyMetadata, }) .superRefine((survey, ctx) => { - const { questions, languages, welcomeCard, endings, isBackButtonHidden } = survey; + const { questions, blocks, languages, welcomeCard, endings, isBackButtonHidden } = survey; + + // Validate: must have questions OR blocks, not both + const hasQuestions = questions.length > 0; + const hasBlocks = blocks.length > 0; + + if (!hasQuestions && !hasBlocks) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Survey must have either questions or blocks", + path: ["questions"], + }); + } + + if (hasQuestions && hasBlocks) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Survey cannot have both questions and blocks. Use one model.", + path: ["blocks"], + }); + } let multiLangIssue: z.IssueData | null; @@ -914,335 +941,807 @@ export const ZSurvey = z } // Custom default validation for each question - questions.forEach((question, questionIndex) => { - multiLangIssue = validateQuestionLabels("headline", question.headline, languages, questionIndex); - if (multiLangIssue) { - ctx.addIssue(multiLangIssue); - } - - if (question.subheader && question.subheader.default.trim() !== "") { - multiLangIssue = validateQuestionLabels("subheader", question.subheader, languages, questionIndex); + if (hasQuestions) { + questions.forEach((question, questionIndex) => { + multiLangIssue = validateQuestionLabels("headline", question.headline, languages, questionIndex); if (multiLangIssue) { ctx.addIssue(multiLangIssue); } - } - const defaultLanguageCode = "default"; - const initialFieldsToValidate = ["buttonLabel", "upperLabel", "lowerLabel", "label", "placeholder"]; - - let fieldsToValidate = - questionIndex === 0 || isBackButtonHidden - ? initialFieldsToValidate - : [...initialFieldsToValidate, "backButtonLabel"]; - - // Skip buttonLabel validation for required NPS and Rating questions - if ( - (question.type === TSurveyQuestionTypeEnum.NPS || question.type === TSurveyQuestionTypeEnum.Rating) && - question.required - ) { - fieldsToValidate = fieldsToValidate.filter((field) => field !== "buttonLabel"); - } - - for (const field of fieldsToValidate) { - // Skip label validation for consent questions as its called checkbox label - if (field === "label" && question.type === TSurveyQuestionTypeEnum.Consent) { - continue; + if (question.subheader && question.subheader.default.trim() !== "") { + multiLangIssue = validateQuestionLabels("subheader", question.subheader, languages, questionIndex); + if (multiLangIssue) { + ctx.addIssue(multiLangIssue); + } } - const questionFieldValue = question[field as keyof typeof question] as TI18nString | null; + const defaultLanguageCode = "default"; + const initialFieldsToValidate = ["buttonLabel", "upperLabel", "lowerLabel", "label", "placeholder"]; + + let fieldsToValidate = + questionIndex === 0 || isBackButtonHidden + ? initialFieldsToValidate + : [...initialFieldsToValidate, "backButtonLabel"]; + + // Skip buttonLabel validation for required NPS and Rating questions if ( - typeof questionFieldValue?.[defaultLanguageCode] !== "undefined" && - questionFieldValue[defaultLanguageCode].trim() !== "" + (question.type === TSurveyQuestionTypeEnum.NPS || + question.type === TSurveyQuestionTypeEnum.Rating) && + question.required ) { - multiLangIssue = validateQuestionLabels(field, questionFieldValue, languages, questionIndex); - if (multiLangIssue) { - ctx.addIssue(multiLangIssue); + fieldsToValidate = fieldsToValidate.filter((field) => field !== "buttonLabel"); + } + + for (const field of fieldsToValidate) { + // Skip label validation for consent questions as its called checkbox label + if (field === "label" && question.type === TSurveyQuestionTypeEnum.Consent) { + continue; + } + + const questionFieldValue = question[field as keyof typeof question] as TI18nString | null; + if ( + typeof questionFieldValue?.[defaultLanguageCode] !== "undefined" && + questionFieldValue[defaultLanguageCode].trim() !== "" + ) { + multiLangIssue = validateQuestionLabels(field, questionFieldValue, languages, questionIndex); + if (multiLangIssue) { + ctx.addIssue(multiLangIssue); + } + } + } + + if (question.type === TSurveyQuestionTypeEnum.OpenText) { + if ( + question.placeholder && + question.placeholder[defaultLanguageCode].trim() !== "" && + languages.length > 1 + ) { + multiLangIssue = validateQuestionLabels( + "placeholder", + question.placeholder, + languages, + questionIndex + ); + if (multiLangIssue) { + ctx.addIssue(multiLangIssue); + } } } - } - if (question.type === TSurveyQuestionTypeEnum.OpenText) { if ( - question.placeholder && - question.placeholder[defaultLanguageCode].trim() !== "" && - languages.length > 1 + question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle || + question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti || + question.type === TSurveyQuestionTypeEnum.Ranking ) { - multiLangIssue = validateQuestionLabels( - "placeholder", - question.placeholder, - languages, - questionIndex - ); - if (multiLangIssue) { - ctx.addIssue(multiLangIssue); - } - } - } - - if ( - question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle || - question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti || - question.type === TSurveyQuestionTypeEnum.Ranking - ) { - question.choices.forEach((choice, choiceIndex) => { - multiLangIssue = validateQuestionLabels( - `Choice ${String(choiceIndex + 1)}`, - choice.label, - languages, - questionIndex, - true - ); - if (multiLangIssue) { - ctx.addIssue(multiLangIssue); - } - }); - - const duplicateChoicesLanguageCodes = findLanguageCodesForDuplicateLabels( - question.choices.map((choice) => choice.label), - languages - ); - - if (duplicateChoicesLanguageCodes.length > 0) { - const invalidLanguageCodes = duplicateChoicesLanguageCodes.map((invalidLanguageCode) => - invalidLanguageCode === "default" - ? (languages.find((lang) => lang.default)?.language.code ?? "default") - : invalidLanguageCode - ); - - const isDefaultOnly = invalidLanguageCodes.length === 1 && invalidLanguageCodes[0] === "default"; - - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Question ${String(questionIndex + 1)} has duplicate choice labels ${isDefaultOnly ? "" : "for the following languages:"}`, - path: ["questions", questionIndex, "choices"], - params: isDefaultOnly ? undefined : { invalidLanguageCodes }, + question.choices.forEach((choice, choiceIndex) => { + multiLangIssue = validateQuestionLabels( + `Choice ${String(choiceIndex + 1)}`, + choice.label, + languages, + questionIndex, + true + ); + if (multiLangIssue) { + ctx.addIssue(multiLangIssue); + } }); - } - } - if (question.type === TSurveyQuestionTypeEnum.Consent) { - multiLangIssue = validateQuestionLabels("consent.label", question.label, languages, questionIndex); - - if (multiLangIssue) { - ctx.addIssue(multiLangIssue); - } - } - - if (question.type === TSurveyQuestionTypeEnum.CTA) { - if (!question.required && question.dismissButtonLabel) { - multiLangIssue = validateQuestionLabels( - "dismissButtonLabel", - question.dismissButtonLabel, - languages, - questionIndex + const duplicateChoicesLanguageCodes = findLanguageCodesForDuplicateLabels( + question.choices.map((choice) => choice.label), + languages ); - if (multiLangIssue) { - ctx.addIssue(multiLangIssue); - } - } - if (question.buttonExternal) { - if (!question.buttonUrl || question.buttonUrl.trim() === "") { + if (duplicateChoicesLanguageCodes.length > 0) { + const invalidLanguageCodes = duplicateChoicesLanguageCodes.map((invalidLanguageCode) => + invalidLanguageCode === "default" + ? (languages.find((lang) => lang.default)?.language.code ?? "default") + : invalidLanguageCode + ); + + const isDefaultOnly = invalidLanguageCodes.length === 1 && invalidLanguageCodes[0] === "default"; + ctx.addIssue({ code: z.ZodIssueCode.custom, - message: `Question ${String(questionIndex + 1)}: Button URL is required when external button is enabled`, - path: ["questions", questionIndex, "buttonUrl"], + message: `Question ${String(questionIndex + 1)} has duplicate choice labels ${isDefaultOnly ? "" : "for the following languages:"}`, + path: ["questions", questionIndex, "choices"], + params: isDefaultOnly ? undefined : { invalidLanguageCodes }, }); - } else { - const parsedButtonUrl = getZSafeUrl.safeParse(question.buttonUrl); - if (!parsedButtonUrl.success) { - const errorMessage = parsedButtonUrl.error.issues[0].message; + } + } + + if (question.type === TSurveyQuestionTypeEnum.Consent) { + multiLangIssue = validateQuestionLabels("consent.label", question.label, languages, questionIndex); + + if (multiLangIssue) { + ctx.addIssue(multiLangIssue); + } + } + + if (question.type === TSurveyQuestionTypeEnum.CTA) { + if (!question.required && question.dismissButtonLabel) { + multiLangIssue = validateQuestionLabels( + "dismissButtonLabel", + question.dismissButtonLabel, + languages, + questionIndex + ); + if (multiLangIssue) { + ctx.addIssue(multiLangIssue); + } + } + + if (question.buttonExternal) { + if (!question.buttonUrl || question.buttonUrl.trim() === "") { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: `Question ${String(questionIndex + 1)}: ${errorMessage}`, + message: `Question ${String(questionIndex + 1)}: Button URL is required when external button is enabled`, path: ["questions", questionIndex, "buttonUrl"], }); + } else { + const parsedButtonUrl = getZSafeUrl.safeParse(question.buttonUrl); + if (!parsedButtonUrl.success) { + const errorMessage = parsedButtonUrl.error.issues[0].message; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Question ${String(questionIndex + 1)}: ${errorMessage}`, + path: ["questions", questionIndex, "buttonUrl"], + }); + } + } + } + } + + if (question.type === TSurveyQuestionTypeEnum.Matrix) { + question.rows.forEach((row, rowIndex) => { + multiLangIssue = validateQuestionLabels( + `Row ${String(rowIndex + 1)}`, + row.label, + languages, + questionIndex, + true + ); + if (multiLangIssue) { + ctx.addIssue(multiLangIssue); + } + }); + + question.columns.forEach((column, columnIndex) => { + multiLangIssue = validateQuestionLabels( + `Column ${String(columnIndex + 1)}`, + column.label, + languages, + questionIndex, + true + ); + if (multiLangIssue) { + ctx.addIssue(multiLangIssue); + } + }); + + const duplicateRowsLanguageCodes = findLanguageCodesForDuplicateLabels( + question.rows.map((row) => row.label), + languages + ); + const duplicateColumnLanguageCodes = findLanguageCodesForDuplicateLabels( + question.columns.map((column) => column.label), + languages + ); + + if (duplicateRowsLanguageCodes.length > 0) { + const invalidLanguageCodes = duplicateRowsLanguageCodes.map((invalidLanguageCode) => + invalidLanguageCode === "default" + ? (languages.find((lang) => lang.default)?.language.code ?? "default") + : invalidLanguageCode + ); + + const isDefaultOnly = invalidLanguageCodes.length === 1 && invalidLanguageCodes[0] === "default"; + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Question ${String(questionIndex + 1)} has duplicate row labels ${isDefaultOnly ? "" : "for the following languages:"}`, + path: ["questions", questionIndex, "rows"], + params: isDefaultOnly ? undefined : { invalidLanguageCodes }, + }); + } + + if (duplicateColumnLanguageCodes.length > 0) { + const invalidLanguageCodes = duplicateColumnLanguageCodes.map((invalidLanguageCode) => + invalidLanguageCode === "default" + ? (languages.find((lang) => lang.default)?.language.code ?? "default") + : invalidLanguageCode + ); + + const isDefaultOnly = invalidLanguageCodes.length === 1 && invalidLanguageCodes[0] === "default"; + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Question ${String(questionIndex + 1)} has duplicate column labels ${isDefaultOnly ? "" : "for the following languages:"}`, + path: ["questions", questionIndex, "columns"], + params: isDefaultOnly ? undefined : { invalidLanguageCodes }, + }); + } + } + + if (question.type === TSurveyQuestionTypeEnum.FileUpload) { + // allowedFileExtensions must have atleast one element + if (question.allowedFileExtensions && question.allowedFileExtensions.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Question ${String(questionIndex + 1)} must have atleast one allowed file extension`, + path: ["questions", questionIndex, "allowedFileExtensions"], + }); + } + } + + if (question.type === TSurveyQuestionTypeEnum.Cal) { + if (question.calHost !== undefined) { + const hostnameRegex = /^(?!-)[a-zA-Z0-9-]{1,63}(? { - multiLangIssue = validateQuestionLabels( - `Row ${String(rowIndex + 1)}`, - row.label, - languages, - questionIndex, - true - ); - if (multiLangIssue) { - ctx.addIssue(multiLangIssue); - } - }); + if (question.type === TSurveyQuestionTypeEnum.ContactInfo) { + const { company, email, firstName, lastName, phone } = question; + const fields = [ + { ...company, label: "Company" }, + { ...email, label: "Email" }, + { ...firstName, label: "First Name" }, + { ...lastName, label: "Last Name" }, + { ...phone, label: "Phone" }, + ]; - question.columns.forEach((column, columnIndex) => { - multiLangIssue = validateQuestionLabels( - `Column ${String(columnIndex + 1)}`, - column.label, - languages, - questionIndex, - true - ); - if (multiLangIssue) { - ctx.addIssue(multiLangIssue); - } - }); - - const duplicateRowsLanguageCodes = findLanguageCodesForDuplicateLabels( - question.rows.map((row) => row.label), - languages - ); - const duplicateColumnLanguageCodes = findLanguageCodesForDuplicateLabels( - question.columns.map((column) => column.label), - languages - ); - - if (duplicateRowsLanguageCodes.length > 0) { - const invalidLanguageCodes = duplicateRowsLanguageCodes.map((invalidLanguageCode) => - invalidLanguageCode === "default" - ? (languages.find((lang) => lang.default)?.language.code ?? "default") - : invalidLanguageCode - ); - - const isDefaultOnly = invalidLanguageCodes.length === 1 && invalidLanguageCodes[0] === "default"; - - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Question ${String(questionIndex + 1)} has duplicate row labels ${isDefaultOnly ? "" : "for the following languages:"}`, - path: ["questions", questionIndex, "rows"], - params: isDefaultOnly ? undefined : { invalidLanguageCodes }, - }); - } - - if (duplicateColumnLanguageCodes.length > 0) { - const invalidLanguageCodes = duplicateColumnLanguageCodes.map((invalidLanguageCode) => - invalidLanguageCode === "default" - ? (languages.find((lang) => lang.default)?.language.code ?? "default") - : invalidLanguageCode - ); - - const isDefaultOnly = invalidLanguageCodes.length === 1 && invalidLanguageCodes[0] === "default"; - - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Question ${String(questionIndex + 1)} has duplicate column labels ${isDefaultOnly ? "" : "for the following languages:"}`, - path: ["questions", questionIndex, "columns"], - params: isDefaultOnly ? undefined : { invalidLanguageCodes }, - }); - } - } - - if (question.type === TSurveyQuestionTypeEnum.FileUpload) { - // allowedFileExtensions must have atleast one element - if (question.allowedFileExtensions && question.allowedFileExtensions.length === 0) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Question ${String(questionIndex + 1)} must have atleast one allowed file extension`, - path: ["questions", questionIndex, "allowedFileExtensions"], - }); - } - } - - if (question.type === TSurveyQuestionTypeEnum.Cal) { - if (question.calHost !== undefined) { - const hostnameRegex = /^(?!-)[a-zA-Z0-9-]{1,63}(? !field.show)) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: `Question ${String(questionIndex + 1)} must have a valid host name`, - path: ["questions", questionIndex, "calHost"], + message: "At least one field must be shown in the Contact Info question", + path: ["questions", questionIndex], }); } - } - } - - if (question.type === TSurveyQuestionTypeEnum.ContactInfo) { - const { company, email, firstName, lastName, phone } = question; - const fields = [ - { ...company, label: "Company" }, - { ...email, label: "Email" }, - { ...firstName, label: "First Name" }, - { ...lastName, label: "Last Name" }, - { ...phone, label: "Phone" }, - ]; - - if (fields.every((field) => !field.show)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "At least one field must be shown in the Contact Info question", - path: ["questions", questionIndex], + fields.forEach((field) => { + const multiLangIssueInPlaceholder = + field.show && + validateQuestionLabels( + `Label for field ${field.label}`, + field.placeholder, + languages, + questionIndex, + true + ); + if (multiLangIssueInPlaceholder) { + ctx.addIssue(multiLangIssueInPlaceholder); + } }); } - fields.forEach((field) => { - const multiLangIssueInPlaceholder = - field.show && - validateQuestionLabels( - `Label for field ${field.label}`, - field.placeholder, - languages, - questionIndex, - true - ); - if (multiLangIssueInPlaceholder) { - ctx.addIssue(multiLangIssueInPlaceholder); + + if (question.type === TSurveyQuestionTypeEnum.Address) { + const { addressLine1, addressLine2, city, state, zip, country } = question; + const fields = [ + { ...addressLine1, label: "Address Line 1" }, + { ...addressLine2, label: "Address Line 2" }, + { ...city, label: "City" }, + { ...state, label: "State" }, + { ...zip, label: "Zip" }, + { ...country, label: "Country" }, + ]; + + if (fields.every((field) => !field.show)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "At least one field must be shown in the Address question", + path: ["questions", questionIndex], + }); } + fields.forEach((field) => { + const multiLangIssueInPlaceholder = + field.show && + validateQuestionLabels( + `Label for field ${field.label}`, + field.placeholder, + languages, + questionIndex, + true + ); + if (multiLangIssueInPlaceholder) { + ctx.addIssue(multiLangIssueInPlaceholder); + } + }); + } + + if (question.logic) { + const logicIssues = validateLogic(survey, questionIndex, question.logic); + + logicIssues.forEach((issue) => { + ctx.addIssue(issue); + }); + } + }); + + const questionsWithCyclicLogic = findQuestionsWithCyclicLogic(questions); + if (questionsWithCyclicLogic.length > 0) { + questionsWithCyclicLogic.forEach((questionId) => { + const questionIndex = questions.findIndex((q) => q.id === questionId); + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Cyclic logic detected 🔃 Please check the logic of question ${String(questionIndex + 1)}.`, + path: ["questions", questionIndex, "logic"], + }); + }); + } + } + + // Blocks validation + if (hasBlocks) { + // 1. Validate block IDs are unique (CUIDs should be unique by design, but validate anyway) + const blockIds = blocks.map((b) => b.id); + const uniqueBlockIds = new Set(blockIds); + if (uniqueBlockIds.size !== blockIds.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Block IDs must be unique", + path: ["blocks", blockIds.findIndex((id, index) => blockIds.indexOf(id) !== index), "id"], }); } - if (question.type === TSurveyQuestionTypeEnum.Address) { - const { addressLine1, addressLine2, city, state, zip, country } = question; - const fields = [ - { ...addressLine1, label: "Address Line 1" }, - { ...addressLine2, label: "Address Line 2" }, - { ...city, label: "City" }, - { ...state, label: "State" }, - { ...zip, label: "Zip" }, - { ...country, label: "Country" }, - ]; - - if (fields.every((field) => !field.show)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "At least one field must be shown in the Address question", - path: ["questions", questionIndex], - }); - } - fields.forEach((field) => { - const multiLangIssueInPlaceholder = - field.show && - validateQuestionLabels( - `Label for field ${field.label}`, - field.placeholder, - languages, - questionIndex, - true - ); - if (multiLangIssueInPlaceholder) { - ctx.addIssue(multiLangIssueInPlaceholder); - } + // 2. Validate block names are unique (for editor usability) + const blockNames = blocks.map((b) => b.name); + const uniqueBlockNames = new Set(blockNames); + if (uniqueBlockNames.size !== blockNames.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Block names must be unique", + path: ["blocks", blockNames.findIndex((name, index) => blockNames.indexOf(name) !== index), "name"], }); } - if (question.logic) { - const logicIssues = validateLogic(survey, questionIndex, question.logic); + // 3. Build map of all elements across all blocks + const allElements = new Map(); + blocks.forEach((block, blockIdx) => { + block.elements.forEach((element, elemIdx) => { + if (allElements.has(element.id)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Element ID "${element.id}" is used in multiple blocks. Element IDs must be unique across all blocks.`, + path: ["blocks", blockIdx, "elements", elemIdx, "id"], + }); + } + allElements.set(element.id, { block: blockIdx, element: elemIdx, data: element }); + }); + }); + // 4. Detailed validation for each block and its elements + blocks.forEach((block, blockIndex) => { + // Validate block button labels + const defaultLanguageCode = "default"; + + if (block.buttonLabel && block.buttonLabel[defaultLanguageCode].trim() !== "") { + // Validate button label for all enabled languages + const enabledLanguages = languages.filter((lang) => lang.enabled); + const languageCodes = enabledLanguages.map((lang) => + lang.default ? "default" : lang.language.code + ); + + for (const languageCode of languageCodes.length === 0 ? ["default"] : languageCodes) { + const labelValue = block.buttonLabel[languageCode]; + if (!labelValue || getTextContent(labelValue).length === 0) { + const invalidLanguageCode = + languageCode === "default" + ? (languages.find((lang) => lang.default)?.language.code ?? "default") + : languageCode; + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `The buttonLabel in block ${String(blockIndex + 1)} is missing for the following languages: ${invalidLanguageCode}`, + path: ["blocks", blockIndex, "buttonLabel"], + params: { invalidLanguageCodes: [invalidLanguageCode] }, + }); + } + } + } + + if (block.backButtonLabel && block.backButtonLabel[defaultLanguageCode].trim() !== "") { + // Validate back button label for all enabled languages + const enabledLanguages = languages.filter((lang) => lang.enabled); + const languageCodes = enabledLanguages.map((lang) => + lang.default ? "default" : lang.language.code + ); + + for (const languageCode of languageCodes.length === 0 ? ["default"] : languageCodes) { + const labelValue = block.backButtonLabel[languageCode]; + if (!labelValue || getTextContent(labelValue).length === 0) { + const invalidLanguageCode = + languageCode === "default" + ? (languages.find((lang) => lang.default)?.language.code ?? "default") + : languageCode; + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `The backButtonLabel in block ${String(blockIndex + 1)} is missing for the following languages: ${invalidLanguageCode}`, + path: ["blocks", blockIndex, "backButtonLabel"], + params: { invalidLanguageCodes: [invalidLanguageCode] }, + }); + } + } + } + + // Validate each element in the block + block.elements.forEach((element, elementIndex) => { + // Validate headline (required for all elements) + let elementMultiLangIssue = validateElementLabels( + "headline", + element.headline, + languages, + blockIndex, + elementIndex + ); + if (elementMultiLangIssue) { + ctx.addIssue(elementMultiLangIssue); + } + + // Validate subheader if present + if (element.subheader && element.subheader[defaultLanguageCode].trim() !== "") { + elementMultiLangIssue = validateElementLabels( + "subheader", + element.subheader, + languages, + blockIndex, + elementIndex + ); + if (elementMultiLangIssue) { + ctx.addIssue(elementMultiLangIssue); + } + } + + // Type-specific validation + if (element.type === TSurveyElementTypeEnum.OpenText) { + if ( + element.placeholder && + element.placeholder[defaultLanguageCode].trim() !== "" && + languages.length > 1 + ) { + elementMultiLangIssue = validateElementLabels( + "placeholder", + element.placeholder, + languages, + blockIndex, + elementIndex + ); + if (elementMultiLangIssue) { + ctx.addIssue(elementMultiLangIssue); + } + } + } + + if ( + element.type === TSurveyElementTypeEnum.MultipleChoiceSingle || + element.type === TSurveyElementTypeEnum.MultipleChoiceMulti || + element.type === TSurveyElementTypeEnum.Ranking + ) { + element.choices.forEach((choice, choiceIndex) => { + elementMultiLangIssue = validateElementLabels( + `Choice ${String(choiceIndex + 1)}`, + choice.label, + languages, + blockIndex, + elementIndex, + true + ); + if (elementMultiLangIssue) { + elementMultiLangIssue.path = [ + "blocks", + blockIndex, + "elements", + elementIndex, + "choices", + choiceIndex, + ]; + ctx.addIssue(elementMultiLangIssue); + } + }); + + const duplicateChoicesLanguageCodes = findLanguageCodesForDuplicateLabels( + element.choices.map((choice) => choice.label), + languages + ); + + if (duplicateChoicesLanguageCodes.length > 0) { + const invalidLanguageCodes = duplicateChoicesLanguageCodes.map((invalidLanguageCode) => + invalidLanguageCode === "default" + ? (languages.find((lang) => lang.default)?.language.code ?? "default") + : invalidLanguageCode + ); + + const isDefaultOnly = + invalidLanguageCodes.length === 1 && invalidLanguageCodes[0] === "default"; + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Element ${String(elementIndex + 1)} in block ${String(blockIndex + 1)} has duplicate choice labels ${isDefaultOnly ? "" : "for the following languages:"}`, + path: ["blocks", blockIndex, "elements", elementIndex, "choices"], + params: isDefaultOnly ? undefined : { invalidLanguageCodes }, + }); + } + } + + if (element.type === TSurveyElementTypeEnum.Consent) { + elementMultiLangIssue = validateElementLabels( + "consent.label", + element.label, + languages, + blockIndex, + elementIndex + ); + + if (elementMultiLangIssue) { + elementMultiLangIssue.path = ["blocks", blockIndex, "elements", elementIndex, "label"]; + ctx.addIssue(elementMultiLangIssue); + } + } + + if (element.type === TSurveyElementTypeEnum.CTA) { + if (!element.required && element.dismissButtonLabel) { + elementMultiLangIssue = validateElementLabels( + "dismissButtonLabel", + element.dismissButtonLabel, + languages, + blockIndex, + elementIndex + ); + if (elementMultiLangIssue) { + elementMultiLangIssue.path = [ + "blocks", + blockIndex, + "elements", + elementIndex, + "dismissButtonLabel", + ]; + ctx.addIssue(elementMultiLangIssue); + } + } + + if (element.buttonExternal) { + if (!element.buttonUrl || element.buttonUrl.trim() === "") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Element ${String(elementIndex + 1)} in block ${String(blockIndex + 1)}: Button URL is required when external button is enabled`, + path: ["blocks", blockIndex, "elements", elementIndex, "buttonUrl"], + }); + } else { + const parsedButtonUrl = getZSafeUrl.safeParse(element.buttonUrl); + if (!parsedButtonUrl.success) { + const errorMessage = parsedButtonUrl.error.issues[0].message; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Element ${String(elementIndex + 1)} in block ${String(blockIndex + 1)}: ${errorMessage}`, + path: ["blocks", blockIndex, "elements", elementIndex, "buttonUrl"], + }); + } + } + } + } + + if (element.type === TSurveyElementTypeEnum.Matrix) { + element.rows.forEach((row, rowIndex) => { + elementMultiLangIssue = validateElementLabels( + `Row ${String(rowIndex + 1)}`, + row.label, + languages, + blockIndex, + elementIndex, + true + ); + if (elementMultiLangIssue) { + elementMultiLangIssue.path = [ + "blocks", + blockIndex, + "elements", + elementIndex, + "rows", + rowIndex, + ]; + ctx.addIssue(elementMultiLangIssue); + } + }); + + element.columns.forEach((column, columnIndex) => { + elementMultiLangIssue = validateElementLabels( + `Column ${String(columnIndex + 1)}`, + column.label, + languages, + blockIndex, + elementIndex, + true + ); + if (elementMultiLangIssue) { + elementMultiLangIssue.path = [ + "blocks", + blockIndex, + "elements", + elementIndex, + "columns", + columnIndex, + ]; + ctx.addIssue(elementMultiLangIssue); + } + }); + + const duplicateRowsLanguageCodes = findLanguageCodesForDuplicateLabels( + element.rows.map((row) => row.label), + languages + ); + const duplicateColumnLanguageCodes = findLanguageCodesForDuplicateLabels( + element.columns.map((column) => column.label), + languages + ); + + if (duplicateRowsLanguageCodes.length > 0) { + const invalidLanguageCodes = duplicateRowsLanguageCodes.map((invalidLanguageCode) => + invalidLanguageCode === "default" + ? (languages.find((lang) => lang.default)?.language.code ?? "default") + : invalidLanguageCode + ); + + const isDefaultOnly = + invalidLanguageCodes.length === 1 && invalidLanguageCodes[0] === "default"; + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Element ${String(elementIndex + 1)} in block ${String(blockIndex + 1)} has duplicate row labels ${isDefaultOnly ? "" : "for the following languages:"}`, + path: ["blocks", blockIndex, "elements", elementIndex, "rows"], + params: isDefaultOnly ? undefined : { invalidLanguageCodes }, + }); + } + + if (duplicateColumnLanguageCodes.length > 0) { + const invalidLanguageCodes = duplicateColumnLanguageCodes.map((invalidLanguageCode) => + invalidLanguageCode === "default" + ? (languages.find((lang) => lang.default)?.language.code ?? "default") + : invalidLanguageCode + ); + + const isDefaultOnly = + invalidLanguageCodes.length === 1 && invalidLanguageCodes[0] === "default"; + + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Element ${String(elementIndex + 1)} in block ${String(blockIndex + 1)} has duplicate column labels ${isDefaultOnly ? "" : "for the following languages:"}`, + path: ["blocks", blockIndex, "elements", elementIndex, "columns"], + params: isDefaultOnly ? undefined : { invalidLanguageCodes }, + }); + } + } + + if (element.type === TSurveyElementTypeEnum.FileUpload) { + if (element.allowedFileExtensions && element.allowedFileExtensions.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Element ${String(elementIndex + 1)} in block ${String(blockIndex + 1)} must have atleast one allowed file extension`, + path: ["blocks", blockIndex, "elements", elementIndex, "allowedFileExtensions"], + }); + } + } + + if (element.type === TSurveyElementTypeEnum.Cal) { + if (element.calHost !== undefined) { + const hostnameRegex = /^(?!-)[a-zA-Z0-9-]{1,63}(? !field.show)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `At least one field must be shown in the Contact Info element ${String(elementIndex + 1)} in block ${String(blockIndex + 1)}`, + path: ["blocks", blockIndex, "elements", elementIndex], + }); + } + fields.forEach((field) => { + const multiLangIssueInPlaceholder = + field.show && + validateElementLabels( + `Label for field ${field.label}`, + field.placeholder, + languages, + blockIndex, + elementIndex, + true + ); + if (multiLangIssueInPlaceholder) { + multiLangIssueInPlaceholder.path = [ + "blocks", + blockIndex, + "elements", + elementIndex, + field.label.toLowerCase().replace(" ", ""), + ]; + ctx.addIssue(multiLangIssueInPlaceholder); + } + }); + } + + if (element.type === TSurveyElementTypeEnum.Address) { + const { addressLine1, addressLine2, city, state, zip, country } = element; + const fields = [ + { ...addressLine1, label: "Address Line 1" }, + { ...addressLine2, label: "Address Line 2" }, + { ...city, label: "City" }, + { ...state, label: "State" }, + { ...zip, label: "Zip" }, + { ...country, label: "Country" }, + ]; + + if (fields.every((field) => !field.show)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `At least one field must be shown in the Address element ${String(elementIndex + 1)} in block ${String(blockIndex + 1)}`, + path: ["blocks", blockIndex, "elements", elementIndex], + }); + } + fields.forEach((field) => { + const multiLangIssueInPlaceholder = + field.show && + validateElementLabels( + `Label for field ${field.label}`, + field.placeholder, + languages, + blockIndex, + elementIndex, + true + ); + if (multiLangIssueInPlaceholder) { + multiLangIssueInPlaceholder.path = [ + "blocks", + blockIndex, + "elements", + elementIndex, + field.label.toLowerCase().replace(/ /g, ""), + ]; + ctx.addIssue(multiLangIssueInPlaceholder); + } + }); + } + }); + + // Validate block logic (conditions, actions, fallback) + const logicIssues = validateBlockLogic(survey, blockIndex, block, allElements); logicIssues.forEach((issue) => { ctx.addIssue(issue); }); - } - }); - - const questionsWithCyclicLogic = findQuestionsWithCyclicLogic(questions); - if (questionsWithCyclicLogic.length > 0) { - questionsWithCyclicLogic.forEach((questionId) => { - const questionIndex = questions.findIndex((q) => q.id === questionId); - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Conditional Logic: Cyclic logic detected 🔃 Please check the logic of question ${String(questionIndex + 1)}.`, - path: ["questions", questionIndex, "logic"], - }); }); + + // 5. Check for cyclic logic in blocks + const blocksWithCyclicLogic = findBlocksWithCyclicLogic(blocks); + if (blocksWithCyclicLogic.length > 0) { + blocksWithCyclicLogic.forEach((blockId) => { + const blockIndex = blocks.findIndex((b) => b.id === blockId); + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Cyclic logic detected in block ${String(blockIndex + 1)} (${blocks[blockIndex].name}).`, + path: ["blocks", blockIndex, "logic"], + }); + }); + } } endings.forEach((ending, index) => { @@ -2410,6 +2909,603 @@ const validateLogic = (survey: TSurvey, questionIndex: number, logic: TSurveyLog return [...logicIssues.flat(), ...(logicFallbackIssue ?? [])]; }; +// ================== BLOCK LOGIC VALIDATION ================== + +const isInvalidOperatorsForElementType = ( + element: TSurveyElement, + operator: TSurveyLogicConditionsOperator +): boolean => { + let isInvalidOperator = false; + + const elementType = element.type; + + if (element.required && operator === "isSkipped") return true; + + switch (elementType) { + case TSurveyElementTypeEnum.OpenText: + switch (element.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", + "lessThan", + "lessEqual", + "greaterThan", + "greaterEqual", + "isSubmitted", + "isSkipped", + ].includes(operator) + ) { + isInvalidOperator = true; + } + break; + } + break; + case TSurveyElementTypeEnum.MultipleChoiceSingle: + case TSurveyElementTypeEnum.MultipleChoiceMulti: + if (!["equals", "doesNotEqual", "isSubmitted", "isSkipped"].includes(operator)) { + isInvalidOperator = true; + } + break; + case TSurveyElementTypeEnum.NPS: + case TSurveyElementTypeEnum.Rating: + if ( + ![ + "equals", + "doesNotEqual", + "lessThan", + "lessEqual", + "greaterThan", + "greaterEqual", + "isSubmitted", + "isSkipped", + ].includes(operator) + ) { + isInvalidOperator = true; + } + break; + case TSurveyElementTypeEnum.CTA: + if (!["isClicked", "isSkipped"].includes(operator)) { + isInvalidOperator = true; + } + break; + case TSurveyElementTypeEnum.Consent: + if (!["isAccepted", "isSkipped"].includes(operator)) { + isInvalidOperator = true; + } + break; + case TSurveyElementTypeEnum.PictureSelection: + if (!["equals", "doesNotEqual", "isSubmitted", "isSkipped"].includes(operator)) { + isInvalidOperator = true; + } + break; + case TSurveyElementTypeEnum.Cal: + if (!["isBooked", "isSkipped"].includes(operator)) { + isInvalidOperator = true; + } + break; + case TSurveyElementTypeEnum.FileUpload: + if (!["isSubmitted", "isSkipped"].includes(operator)) { + isInvalidOperator = true; + } + break; + case TSurveyElementTypeEnum.Date: + if (!["equals", "doesNotEqual", "isSubmitted", "isSkipped"].includes(operator)) { + isInvalidOperator = true; + } + break; + case TSurveyElementTypeEnum.Matrix: + if ( + ![ + "isPartiallySubmitted", + "isCompletelySubmitted", + "equals", + "doesNotEqual", + "isSubmitted", + "isSkipped", + ].includes(operator) + ) { + isInvalidOperator = true; + } + break; + case TSurveyElementTypeEnum.Address: + if ( + ![ + "isPartiallySubmitted", + "isCompletelySubmitted", + "isEmpty", + "isNotEmpty", + "isSubmitted", + "isSkipped", + ].includes(operator) + ) { + isInvalidOperator = true; + } + break; + case TSurveyElementTypeEnum.ContactInfo: + if ( + ![ + "isPartiallySubmitted", + "isCompletelySubmitted", + "isEmpty", + "isNotEmpty", + "isSubmitted", + "isSkipped", + ].includes(operator) + ) { + isInvalidOperator = true; + } + break; + case TSurveyElementTypeEnum.Ranking: + if (!["equals", "doesNotEqual", "isSubmitted", "isSkipped"].includes(operator)) { + isInvalidOperator = true; + } + break; + } + + return isInvalidOperator; +}; + +const validateBlockConditions = ( + survey: TSurvey, + blockIndex: number, + logicIndex: number, + conditions: TConditionGroup, + allElements: Map +): z.ZodIssue[] => { + const issues: z.ZodIssue[] = []; + + const validateSingleCondition = (condition: TSingleCondition): void => { + const { leftOperand, operator, rightOperand } = condition; + + // Validate left operand + if (leftOperand.type === "question") { + const elementId = leftOperand.value; + const elementInfo = allElements.get(elementId); + + if (!elementInfo) { + issues.push({ + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Element ID ${elementId} does not exist in logic no: ${String(logicIndex + 1)} of block ${String(blockIndex + 1)}`, + path: ["blocks", blockIndex, "logic", logicIndex, "conditions"], + }); + return; + } else if (blockIndex < elementInfo.block) { + issues.push({ + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Block ${String(blockIndex + 1)} cannot refer to an element in block ${String(elementInfo.block + 1)} that appears later in the survey`, + path: ["blocks", blockIndex, "logic", logicIndex, "conditions"], + }); + return; + } + + const element = elementInfo.data; + + // Validate operator based on element type + const isInvalidOperator = isInvalidOperatorsForElementType(element, operator); + if (isInvalidOperator) { + issues.push({ + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Invalid operator "${operator}" for element type "${element.type}" in logic no: ${String(logicIndex + 1)} of block ${String(blockIndex + 1)}`, + path: ["blocks", blockIndex, "logic", logicIndex, "conditions"], + }); + } + + // Validate right operand + if ( + [ + "isSubmitted", + "isSkipped", + "isClicked", + "isAccepted", + "isBooked", + "isPartiallySubmitted", + "isCompletelySubmitted", + "isEmpty", + "isNotEmpty", + ].includes(operator) + ) { + if (rightOperand !== undefined) { + issues.push({ + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Right operand should not be defined for operator "${operator}" in logic no: ${String(logicIndex + 1)} of block ${String(blockIndex + 1)}`, + path: ["blocks", blockIndex, "logic", logicIndex, "conditions"], + }); + } + return; + } + + if (element.type === TSurveyElementTypeEnum.OpenText) { + // Validate right operand + if (rightOperand?.type === "question") { + const elemId = rightOperand.value; + const elem = allElements.get(elemId); + + if (!elem) { + issues.push({ + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Element ID ${elemId} does not exist in logic no: ${String(logicIndex + 1)} of block ${String(blockIndex + 1)}`, + path: ["blocks", blockIndex, "logic", logicIndex, "conditions"], + }); + } else { + const validElementTypes = [TSurveyElementTypeEnum.OpenText]; + + if (element.inputType === "number") { + validElementTypes.push(...[TSurveyElementTypeEnum.Rating, TSurveyElementTypeEnum.NPS]); + } + + if (["equals", "doesNotEqual"].includes(condition.operator)) { + if (element.inputType !== "number") { + validElementTypes.push( + ...[ + TSurveyElementTypeEnum.Date, + TSurveyElementTypeEnum.MultipleChoiceSingle, + TSurveyElementTypeEnum.MultipleChoiceMulti, + ] + ); + } + } + + if (!validElementTypes.includes(elem.data.type)) { + issues.push({ + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Invalid element type "${elem.data.type}" for right operand in logic no: ${String(logicIndex + 1)} of block ${String(blockIndex + 1)}`, + path: ["blocks", blockIndex, "logic", logicIndex, "conditions"], + }); + } + } + } else if (rightOperand?.type === "variable") { + const variableId = rightOperand.value; + const variable = survey.variables.find((v) => v.id === variableId); + + if (!variable) { + issues.push({ + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Variable ID ${variableId} does not exist in logic no: ${String(logicIndex + 1)} of block ${String(blockIndex + 1)}`, + path: ["blocks", blockIndex, "logic", logicIndex, "conditions"], + }); + } + } else if (rightOperand?.type === "hiddenField") { + const fieldId = rightOperand.value; + const field = survey.hiddenFields.fieldIds?.find((id) => id === fieldId); + + if (!field) { + issues.push({ + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Hidden field ID ${fieldId} does not exist in logic no: ${String(logicIndex + 1)} of block ${String(blockIndex + 1)}`, + path: ["blocks", blockIndex, "logic", logicIndex, "conditions"], + }); + } + } else if (rightOperand?.type === "static") { + if (!rightOperand.value) { + issues.push({ + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Static value is required in logic no: ${String(logicIndex + 1)} of block ${String(blockIndex + 1)}`, + path: ["blocks", blockIndex, "logic", logicIndex, "conditions"], + }); + } + } + } else if (element.type === TSurveyElementTypeEnum.MultipleChoiceSingle) { + if (rightOperand?.type !== "static") { + issues.push({ + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Right operand should be a static value for "${operator}" in logic no: ${String(logicIndex + 1)} of block ${String(blockIndex + 1)}`, + path: ["blocks", blockIndex, "logic", logicIndex, "conditions"], + }); + } else if (condition.operator === "equals" || condition.operator === "doesNotEqual") { + if (typeof rightOperand.value !== "string") { + issues.push({ + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Right operand should be a string for "${operator}" in logic no: ${String(logicIndex + 1)} of block ${String(blockIndex + 1)}`, + path: ["blocks", blockIndex, "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: `Conditional Logic: Variable ID ${variableId} does not exist in logic no: ${String(logicIndex + 1)} of block ${String(blockIndex + 1)}`, + path: ["blocks", blockIndex, "logic", logicIndex, "conditions"], + }); + return; + } + + if (rightOperand?.type === "variable") { + const rightVariableId = rightOperand.value; + const rightVariable = survey.variables.find((v) => v.id === rightVariableId); + + if (!rightVariable) { + issues.push({ + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Variable ID ${rightVariableId} does not exist in logic no: ${String(logicIndex + 1)} of block ${String(blockIndex + 1)}`, + path: ["blocks", blockIndex, "logic", logicIndex, "conditions"], + }); + } + } + } else { + // leftOperand.type === "hiddenField" + const fieldId = leftOperand.value; + const field = survey.hiddenFields.fieldIds?.find((id) => id === fieldId); + + if (!field) { + issues.push({ + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Hidden field ID ${fieldId} does not exist in logic no: ${String(logicIndex + 1)} of block ${String(blockIndex + 1)}`, + path: ["blocks", blockIndex, "logic", logicIndex, "conditions"], + }); + } + } + }; + + const processConditionGroup = (group: TConditionGroup): void => { + if (isConditionGroup(group)) { + group.conditions.forEach((condition) => { + if (isConditionGroup(condition)) { + processConditionGroup(condition); + } else { + validateSingleCondition(condition); + } + }); + } else { + validateSingleCondition(group); + } + }; + + processConditionGroup(conditions); + return issues; +}; + +const validateBlockActions = ( + survey: TSurvey, + blockIndex: number, + logicIndex: number, + actions: TSurveyBlockLogicAction[], + currentBlock: TSurveyBlock, + allElements: Map +): z.ZodIssue[] => { + 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: `Conditional Logic: Variable ID ${action.variableId} does not exist in logic no: ${String(logicIndex + 1)} of block ${String(blockIndex + 1)}`, + path: ["blocks", blockIndex, "logic", logicIndex], + }; + } + + if (action.value.type === "variable") { + const selectedVariable = survey.variables.find((v) => v.id === action.value.value); + + if (!selectedVariable || selectedVariable.type !== variable.type) { + return { + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Invalid variable type for variable in logic no: ${String(logicIndex + 1)} of block ${String(blockIndex + 1)}`, + path: ["blocks", blockIndex, "logic", logicIndex], + }; + } + } + + if (variable.type === "text") { + if (action.value.type === "question") { + const allowedElements = [ + TSurveyElementTypeEnum.OpenText, + TSurveyElementTypeEnum.MultipleChoiceSingle, + TSurveyElementTypeEnum.Rating, + TSurveyElementTypeEnum.NPS, + TSurveyElementTypeEnum.Date, + ]; + + const selectedElement = allElements.get(action.value.value); + + if (!selectedElement || !allowedElements.includes(selectedElement.data.type)) { + return { + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Invalid element type for text variable in logic no: ${String(logicIndex + 1)} of block ${String(blockIndex + 1)}`, + path: ["blocks", blockIndex, "logic", logicIndex], + }; + } + } + + return undefined; + } + + if (action.value.type === "question") { + const allowedElements = [TSurveyElementTypeEnum.Rating, TSurveyElementTypeEnum.NPS]; + + const selectedElement = allElements.get(action.value.value); + + if ( + !selectedElement || + (!allowedElements.includes(selectedElement.data.type) && + selectedElement.data.type === TSurveyElementTypeEnum.OpenText && + selectedElement.data.inputType !== "number") + ) { + return { + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Invalid element type for number variable in logic no: ${String(logicIndex + 1)} of block ${String(blockIndex + 1)}`, + path: ["blocks", blockIndex, "logic", logicIndex], + }; + } + } + } else if (action.objective === "requireAnswer") { + // requireAnswer must target an element OUTSIDE the current block (in a future block) + const targetElementId = action.target; + const targetElementInfo = allElements.get(targetElementId); + + if (!targetElementInfo) { + return { + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Element ID ${targetElementId} does not exist for requireAnswer action in logic no: ${String(logicIndex + 1)} of block ${String(blockIndex + 1)}`, + path: ["blocks", blockIndex, "logic", logicIndex], + }; + } + + // Check if element is in the current block (not allowed) + if (targetElementInfo.block === blockIndex) { + return { + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Element ${targetElementId} cannot be in the current block for requireAnswer action in logic no: ${String(logicIndex + 1)} of block ${String(blockIndex + 1)}. RequireAnswer must target elements in other blocks.`, + path: ["blocks", blockIndex, "logic", logicIndex], + }; + } + + // Check if element is in a previous block (should target future blocks) + if (targetElementInfo.block < blockIndex) { + return { + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Element ${targetElementId} is in a previous block (block ${String(targetElementInfo.block + 1)}). RequireAnswer should target elements in future blocks after block ${String(blockIndex + 1)}.`, + path: ["blocks", blockIndex, "logic", logicIndex], + }; + } + + // Check if element is optional (not required) + if (targetElementInfo.data.required) { + return { + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Element ${targetElementId} in block ${String(targetElementInfo.block + 1)} is already required in logic no: ${String(logicIndex + 1)} of block ${String(blockIndex + 1)}`, + path: ["blocks", blockIndex, "logic", logicIndex], + }; + } + } else { + // action.objective === "jumpToBlock" + const targetBlockId = action.target; + const blockIds = survey.blocks.map((b) => b.id); + const endingIds = survey.endings.map((ending) => ending.id); + const possibleTargets = [...blockIds, ...endingIds]; + + if (!possibleTargets.includes(targetBlockId)) { + return { + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Block ID ${targetBlockId} does not exist in logic no: ${String(logicIndex + 1)} of block ${String(blockIndex + 1)}`, + path: ["blocks", blockIndex, "logic", logicIndex], + }; + } + + // Cannot jump to the current block + if (targetBlockId === currentBlock.id) { + return { + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Cannot jump to the current block in logic no: ${String(logicIndex + 1)} of block ${String(blockIndex + 1)}`, + path: ["blocks", blockIndex, "logic", logicIndex], + }; + } + } + + return undefined; + }); + + const jumpToBlockActions = actions.filter((action) => action.objective === "jumpToBlock"); + if (jumpToBlockActions.length > 1) { + actionIssues.push({ + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Multiple jump actions are not allowed in logic no: ${String(logicIndex + 1)} of block ${String(blockIndex + 1)}`, + path: ["blocks", blockIndex, "logic"], + }); + } + + const filteredActionIssues = actionIssues.filter((issue): issue is ZodIssue => issue !== undefined); + return filteredActionIssues; +}; + +const validateBlockLogicFallback = ( + survey: TSurvey, + blockIndex: number, + block: TSurveyBlock +): z.ZodIssue[] | undefined => { + if (!block.logicFallback) return; + + if (!block.logic?.length && block.logicFallback) { + return [ + { + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Fallback logic is defined without any logic in block ${String(blockIndex + 1)}`, + path: ["blocks", blockIndex], + }, + ]; + } else if (block.id === block.logicFallback) { + return [ + { + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Fallback logic is defined with the same block in block ${String(blockIndex + 1)}`, + path: ["blocks", blockIndex], + }, + ]; + } + + const possibleFallbackIds: string[] = []; + + survey.blocks.forEach((b, idx) => { + if (idx !== blockIndex) { + possibleFallbackIds.push(b.id); + } + }); + + survey.endings.forEach((e) => { + possibleFallbackIds.push(e.id); + }); + + if (!possibleFallbackIds.includes(block.logicFallback)) { + return [ + { + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Fallback block ID ${block.logicFallback} does not exist in block ${String(blockIndex + 1)}`, + path: ["blocks", blockIndex], + }, + ]; + } +}; + +const validateBlockLogic = ( + survey: TSurvey, + blockIndex: number, + block: TSurveyBlock, + allElements: Map +): z.ZodIssue[] => { + const logicFallbackIssue = validateBlockLogicFallback(survey, blockIndex, block); + + if (!block.logic || block.logic.length === 0) { + return logicFallbackIssue ?? []; + } + + const logicIssues = block.logic.map((logicItem, logicIndex) => { + return [ + ...validateBlockConditions(survey, blockIndex, logicIndex, logicItem.conditions, allElements), + ...validateBlockActions(survey, blockIndex, logicIndex, logicItem.actions, block, allElements), + ]; + }); + + return [...logicIssues.flat(), ...(logicFallbackIssue ?? [])]; +}; + // 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() .omit({ createdAt: true, updatedAt: true, followUps: true }) @@ -2458,6 +3554,7 @@ export const ZSurveyCreateInput = makeSchemaOptional(ZSurvey.innerType()) .extend({ name: z.string(), // Keep name required questions: ZSurvey.innerType().shape.questions, // Keep questions required and with its original validation + blocks: ZSurveyBlocks.default([]), languages: z.array(ZSurveyLanguage).default([]), welcomeCard: ZSurveyWelcomeCard.default({ enabled: false, @@ -2483,6 +3580,7 @@ export const ZSurveyCreateInputWithEnvironmentId = makeSchemaOptional(ZSurvey.in name: z.string(), // Keep name required environmentId: z.string(), questions: ZSurvey.innerType().shape.questions, // Keep questions required and with its original validation + blocks: ZSurveyBlocks.default([]), languages: z.array(ZSurveyLanguage).default([]), welcomeCard: ZSurveyWelcomeCard.default({ enabled: false, diff --git a/packages/types/surveys/validation.ts b/packages/types/surveys/validation.ts index 27b629fac1..1060722433 100644 --- a/packages/types/surveys/validation.ts +++ b/packages/types/surveys/validation.ts @@ -299,22 +299,28 @@ const findJumpToQuestionActions = (actions: TSurveyLogicAction[]): TActionJumpTo return actions.filter((action): action is TActionJumpToQuestion => action.objective === "jumpToQuestion"); }; -// function to validate hidden field or question id +// function to validate hidden field or question id or element id export const validateId = ( - type: "Hidden field" | "Question", + type: "Hidden field" | "Question" | "Element", field: string, existingQuestionIds: string[], existingEndingCardIds: string[], - existingHiddenFieldIds: string[] + existingHiddenFieldIds: string[], + existingElementIds?: string[] ): string | null => { if (field.trim() === "") { return `Please enter a ${type} Id.`; } - const combinedIds = [...existingQuestionIds, ...existingHiddenFieldIds, ...existingEndingCardIds]; + const combinedIds = [ + ...existingQuestionIds, + ...existingHiddenFieldIds, + ...existingEndingCardIds, + ...(existingElementIds ?? []), + ]; if (combinedIds.findIndex((id) => id.toLowerCase() === field.toLowerCase()) !== -1) { - return `${type} ID already exists in questions or hidden fields.`; + return `${type} ID already exists in questions, hidden fields, or elements.`; } if (FORBIDDEN_IDS.includes(field)) {