diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary.tsx index 0174072dc0..4c2e020b09 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary.tsx @@ -1,8 +1,8 @@ "use client"; import { useTranslation } from "react-i18next"; +import { type TI18nString } from "@formbricks/types/i18n"; import { - TI18nString, TSurvey, TSurveyQuestionId, TSurveyQuestionSummaryConsent, diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MatrixQuestionSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MatrixQuestionSummary.tsx index fc6677a894..3d4d5f4e04 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MatrixQuestionSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MatrixQuestionSummary.tsx @@ -1,8 +1,8 @@ "use client"; import { useTranslation } from "react-i18next"; +import { type TI18nString } from "@formbricks/types/i18n"; import { - TI18nString, TSurvey, TSurveyQuestionId, TSurveyQuestionSummaryMatrix, diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.tsx index 5c0336f0d7..3709a4f425 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/MultipleChoiceSummary.tsx @@ -4,8 +4,8 @@ import { InboxIcon } from "lucide-react"; import Link from "next/link"; import { Fragment, useState } from "react"; import { useTranslation } from "react-i18next"; +import { TI18nString } from "@formbricks/types/i18n"; import { - TI18nString, TSurvey, TSurveyQuestionId, TSurveyQuestionSummaryMultipleChoice, diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx index f4853f8b21..fad2328b98 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/NPSSummary.tsx @@ -1,8 +1,8 @@ "use client"; import { useTranslation } from "react-i18next"; +import { type TI18nString } from "@formbricks/types/i18n"; import { - TI18nString, TSurvey, TSurveyQuestionId, TSurveyQuestionSummaryNps, diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.tsx index 7740261ea0..cc32fadee3 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/PictureChoiceSummary.tsx @@ -3,8 +3,8 @@ import { InboxIcon } from "lucide-react"; import Image from "next/image"; import { useTranslation } from "react-i18next"; +import { type TI18nString } from "@formbricks/types/i18n"; import { - TI18nString, TSurvey, TSurveyQuestionId, TSurveyQuestionSummaryPictureSelection, diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx index f0d94e7505..30a65eac35 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/RatingSummary.tsx @@ -3,8 +3,8 @@ import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; +import { type TI18nString } from "@formbricks/types/i18n"; import { - TI18nString, TSurvey, TSurveyQuestionId, TSurveyQuestionSummaryRating, diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx index 83f14f5536..8739089caf 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList.tsx @@ -3,8 +3,8 @@ import { toast } from "react-hot-toast"; import { useTranslation } from "react-i18next"; import { TEnvironment } from "@formbricks/types/environment"; -import { TI18nString, TSurveyQuestionId, TSurveySummary } from "@formbricks/types/surveys/types"; -import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; +import { TI18nString } from "@formbricks/types/i18n"; +import { TSurveyQuestionId, TSurveyQuestionTypeEnum, TSurveySummary } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/link-settings-tab.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/link-settings-tab.tsx index 4b81de084a..6d6cb97821 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/link-settings-tab.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/link-settings-tab.tsx @@ -5,7 +5,8 @@ import { useForm } from "react-hook-form"; import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils"; -import { TI18nString, TSurvey, TSurveyMetadata } from "@formbricks/types/surveys/types"; +import { TI18nString } from "@formbricks/types/i18n"; +import { TSurvey, TSurveyMetadata } from "@formbricks/types/surveys/types"; import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context"; import { createI18nString, extractLanguageCodes, getEnabledLanguages } from "@/lib/i18n/utils"; import { updateSurveyAction } from "@/modules/survey/editor/actions"; 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/i18n.lock b/apps/web/i18n.lock index fe044497c9..9453c75649 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -915,15 +915,12 @@ checksums: environments/settings/billing/manage_subscription: 31cafd367fc70d656d8dd979d537dc96 environments/settings/billing/monthly: 818f1192e32bb855597f930d3e78806e environments/settings/billing/monthly_identified_users: 0795735f6b241d31edac576a77dd7e55 - environments/settings/billing/per_month: 64e96490ee2d7811496cf04adae30aa4 - environments/settings/billing/per_year: bf02408d157486e53c15a521a5645617 environments/settings/billing/plan_upgraded_successfully: 52e2a258cc9ca8a512c288bf6f18cf37 environments/settings/billing/premium_support_with_slas: 2e33d4442c16bfececa6cae7b2081e5d environments/settings/billing/remove_branding: 88b6b818750e478bfa153b33dd658280 environments/settings/billing/startup: 4c4ac5a0b9dc62100bca6c6465f31c4c environments/settings/billing/startup_description: 964fcb2c77f49b80266c94606e3f4506 environments/settings/billing/switch_plan: fb3e1941051a4273ca29224803570f4b - environments/settings/billing/switch_plan_confirmation_text: 910a6df56964619975c6ed5651a55db7 environments/settings/billing/team_access_roles: 1cc4af14e589f6c09ab92a4f21958049 environments/settings/billing/unable_to_upgrade_plan: 50fc725609411d139e534c85eeb2879e environments/settings/billing/unlimited_miu: 29c3f5bd01c2a09fdf1d3601665ce90f diff --git a/apps/web/lib/i18n/utils.ts b/apps/web/lib/i18n/utils.ts index 4f07251bd3..338b823fad 100644 --- a/apps/web/lib/i18n/utils.ts +++ b/apps/web/lib/i18n/utils.ts @@ -1,6 +1,7 @@ import { iso639Languages } from "@formbricks/i18n-utils/src/utils"; +import { TI18nString } from "@formbricks/types/i18n"; import { TLanguage } from "@formbricks/types/project"; -import { TI18nString, TSurveyLanguage } from "@formbricks/types/surveys/types"; +import { TSurveyLanguage } from "@formbricks/types/surveys/types"; import { structuredClone } from "@/lib/pollyfills/structuredClone"; // Helper function to create an i18nString from a regular string. 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/lib/surveyLogic/utils.test.ts b/apps/web/lib/surveyLogic/utils.test.ts index ba51e25f07..63ea814801 100644 --- a/apps/web/lib/surveyLogic/utils.test.ts +++ b/apps/web/lib/surveyLogic/utils.test.ts @@ -1,13 +1,8 @@ import { describe, expect, test, vi } from "vitest"; import { TJsEnvironmentStateSurvey } from "@formbricks/types/js"; import { TResponseData, TResponseVariables } from "@formbricks/types/responses"; -import { - TConditionGroup, - TSingleCondition, - TSurveyLogic, - TSurveyLogicAction, - TSurveyQuestionTypeEnum, -} from "@formbricks/types/surveys/types"; +import { TConditionGroup, TSingleCondition } from "@formbricks/types/surveys/logic"; +import { TSurveyLogic, TSurveyLogicAction, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; import { addConditionBelow, createGroupFromResource, diff --git a/apps/web/lib/surveyLogic/utils.ts b/apps/web/lib/surveyLogic/utils.ts index 1ecde53ba2..6571fc682f 100644 --- a/apps/web/lib/surveyLogic/utils.ts +++ b/apps/web/lib/surveyLogic/utils.ts @@ -1,11 +1,10 @@ import { createId } from "@paralleldrive/cuid2"; import { TJsEnvironmentStateSurvey } from "@formbricks/types/js"; import { TResponseData, TResponseVariables } from "@formbricks/types/responses"; +import { TConditionGroup, TSingleCondition } from "@formbricks/types/surveys/logic"; import { TActionCalculate, TActionObjective, - TConditionGroup, - TSingleCondition, TSurveyLogic, TSurveyLogicAction, TSurveyQuestion, diff --git a/apps/web/lib/utils/recall.ts b/apps/web/lib/utils/recall.ts index 6efa2b1be4..042fb2eb3e 100644 --- a/apps/web/lib/utils/recall.ts +++ b/apps/web/lib/utils/recall.ts @@ -1,5 +1,6 @@ +import { type TI18nString } from "@formbricks/types/i18n"; import { TResponseData, TResponseDataValue, TResponseVariables } from "@formbricks/types/responses"; -import { TI18nString, TSurvey, TSurveyQuestion, TSurveyRecallItem } from "@formbricks/types/surveys/types"; +import { TSurvey, TSurveyQuestion, TSurveyRecallItem } from "@formbricks/types/surveys/types"; import { getTextContent } from "@formbricks/types/surveys/validation"; import { getLocalizedValue } from "@/lib/i18n/utils"; import { structuredClone } from "@/lib/pollyfills/structuredClone"; 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/ee/multi-language-surveys/components/localized-editor.tsx b/apps/web/modules/ee/multi-language-surveys/components/localized-editor.tsx index 64b39280f0..0078b7c6b4 100644 --- a/apps/web/modules/ee/multi-language-surveys/components/localized-editor.tsx +++ b/apps/web/modules/ee/multi-language-surveys/components/localized-editor.tsx @@ -3,7 +3,8 @@ import type { Dispatch, SetStateAction } from "react"; import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import type { TI18nString, TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types"; +import type { TI18nString } from "@formbricks/types/i18n"; +import type { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types"; import { getTextContent, isValidHTML } from "@formbricks/types/surveys/validation"; import { TUserLocale } from "@formbricks/types/user"; import { md } from "@/lib/markdownIt"; diff --git a/apps/web/modules/survey/components/question-form-input/components/multi-lang-wrapper.tsx b/apps/web/modules/survey/components/question-form-input/components/multi-lang-wrapper.tsx index 12af525fd2..014fee2f27 100644 --- a/apps/web/modules/survey/components/question-form-input/components/multi-lang-wrapper.tsx +++ b/apps/web/modules/survey/components/question-form-input/components/multi-lang-wrapper.tsx @@ -2,7 +2,8 @@ import { ReactNode, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { TI18nString, TSurvey, TSurveyRecallItem } from "@formbricks/types/surveys/types"; +import { TI18nString } from "@formbricks/types/i18n"; +import { TSurvey, TSurveyRecallItem } from "@formbricks/types/surveys/types"; import { getTextContent } from "@formbricks/types/surveys/validation"; import { TUserLocale } from "@formbricks/types/user"; import { getEnabledLanguages } from "@/lib/i18n/utils"; diff --git a/apps/web/modules/survey/components/question-form-input/index.tsx b/apps/web/modules/survey/components/question-form-input/index.tsx index 2666394756..a5cfb7f3dd 100644 --- a/apps/web/modules/survey/components/question-form-input/index.tsx +++ b/apps/web/modules/survey/components/question-form-input/index.tsx @@ -5,8 +5,8 @@ import { debounce } from "lodash"; import { ImagePlusIcon, TrashIcon } from "lucide-react"; import { useCallback, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { type TI18nString } from "@formbricks/types/i18n"; import { - TI18nString, TSurvey, TSurveyEndScreenCard, TSurveyQuestion, diff --git a/apps/web/modules/survey/components/question-form-input/utils.test.ts b/apps/web/modules/survey/components/question-form-input/utils.test.ts index 10379e2d0f..9ad4ca101a 100644 --- a/apps/web/modules/survey/components/question-form-input/utils.test.ts +++ b/apps/web/modules/survey/components/question-form-input/utils.test.ts @@ -1,8 +1,8 @@ import "@testing-library/jest-dom/vitest"; -import { TFunction } from "react-i18next"; +import { TFunction } from "i18next"; import { beforeEach, describe, expect, test, vi } from "vitest"; +import { type TI18nString } from "@formbricks/types/i18n"; import { - TI18nString, TSurvey, TSurveyMultipleChoiceQuestion, TSurveyQuestion, @@ -384,13 +384,13 @@ describe("utils", () => { describe("getPlaceHolderById", () => { test("returns placeholder for headline", () => { - const t = vi.fn((key) => `Translated: ${key}`) as TFunction; + const t = vi.fn((key) => `Translated: ${key}`) as unknown as TFunction; const result = getPlaceHolderById("headline", t); expect(result).toBe("Translated: environments.surveys.edit.your_question_here_recall_information_with"); }); test("returns placeholder for subheader", () => { - const t = vi.fn((key) => `Translated: ${key}`) as TFunction; + const t = vi.fn((key) => `Translated: ${key}`) as unknown as TFunction; const result = getPlaceHolderById("subheader", t); expect(result).toBe( "Translated: environments.surveys.edit.your_description_here_recall_information_with" @@ -398,7 +398,7 @@ describe("utils", () => { }); test("returns empty string for unknown id", () => { - const t = vi.fn((key) => `Translated: ${key}`) as TFunction; + const t = vi.fn((key) => `Translated: ${key}`) as unknown as TFunction; const result = getPlaceHolderById("unknown", t); expect(result).toBe(""); }); diff --git a/apps/web/modules/survey/components/question-form-input/utils.ts b/apps/web/modules/survey/components/question-form-input/utils.ts index c407c81592..fb923908b9 100644 --- a/apps/web/modules/survey/components/question-form-input/utils.ts +++ b/apps/web/modules/survey/components/question-form-input/utils.ts @@ -1,6 +1,6 @@ import { TFunction } from "i18next"; +import { type TI18nString } from "@formbricks/types/i18n"; import { - TI18nString, TSurvey, TSurveyMatrixQuestion, TSurveyMultipleChoiceQuestion, diff --git a/apps/web/modules/survey/editor/components/logic-editor-actions.tsx b/apps/web/modules/survey/editor/components/logic-editor-actions.tsx index 71dff37920..bce82a7064 100644 --- a/apps/web/modules/survey/editor/components/logic-editor-actions.tsx +++ b/apps/web/modules/survey/editor/components/logic-editor-actions.tsx @@ -5,8 +5,10 @@ import { CopyIcon, CornerDownRightIcon, EllipsisVerticalIcon, PlusIcon, TrashIco import { useTranslation } from "react-i18next"; import { TActionNumberVariableCalculateOperator, - TActionObjective, TActionTextVariableCalculateOperator, +} from "@formbricks/types/surveys/logic"; +import { + TActionObjective, TActionVariableValueType, TSurvey, TSurveyLogic, diff --git a/apps/web/modules/survey/editor/components/logic-editor-conditions.tsx b/apps/web/modules/survey/editor/components/logic-editor-conditions.tsx index a905f85f6a..1393081243 100644 --- a/apps/web/modules/survey/editor/components/logic-editor-conditions.tsx +++ b/apps/web/modules/survey/editor/components/logic-editor-conditions.tsx @@ -1,7 +1,8 @@ "use client"; import { useTranslation } from "react-i18next"; -import { TConditionGroup, TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types"; +import { TConditionGroup } from "@formbricks/types/surveys/logic"; +import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types"; import { createSharedConditionsFactory } from "@/modules/survey/editor/lib/shared-conditions-factory"; import { getDefaultOperatorForQuestion } from "@/modules/survey/editor/lib/utils"; import { ConditionsEditor } from "@/modules/ui/components/conditions-editor"; diff --git a/apps/web/modules/survey/editor/components/matrix-question-form.tsx b/apps/web/modules/survey/editor/components/matrix-question-form.tsx index eed08e23c1..c16e15daaf 100644 --- a/apps/web/modules/survey/editor/components/matrix-question-form.tsx +++ b/apps/web/modules/survey/editor/components/matrix-question-form.tsx @@ -8,7 +8,8 @@ import { PlusIcon } from "lucide-react"; import { type JSX, useCallback } from "react"; import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; -import { TI18nString, TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/surveys/types"; +import { TI18nString } from "@formbricks/types/i18n"; +import { TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils"; import { QuestionFormInput } from "@/modules/survey/components/question-form-input"; diff --git a/apps/web/modules/survey/editor/components/matrix-sortable-item.tsx b/apps/web/modules/survey/editor/components/matrix-sortable-item.tsx index a73766919b..63d55c8dfb 100644 --- a/apps/web/modules/survey/editor/components/matrix-sortable-item.tsx +++ b/apps/web/modules/survey/editor/components/matrix-sortable-item.tsx @@ -5,12 +5,8 @@ import { CSS } from "@dnd-kit/utilities"; import { GripVerticalIcon, TrashIcon } from "lucide-react"; import type { JSX } from "react"; import { useTranslation } from "react-i18next"; -import { - TI18nString, - TSurvey, - TSurveyMatrixQuestion, - TSurveyMatrixQuestionChoice, -} from "@formbricks/types/surveys/types"; +import { type TI18nString } from "@formbricks/types/i18n"; +import { TSurvey, TSurveyMatrixQuestion, TSurveyMatrixQuestionChoice } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { QuestionFormInput } from "@/modules/survey/components/question-form-input"; import { Button } from "@/modules/ui/components/button"; diff --git a/apps/web/modules/survey/editor/components/multiple-choice-question-form.tsx b/apps/web/modules/survey/editor/components/multiple-choice-question-form.tsx index df12af8228..5fb133959a 100644 --- a/apps/web/modules/survey/editor/components/multiple-choice-question-form.tsx +++ b/apps/web/modules/survey/editor/components/multiple-choice-question-form.tsx @@ -8,8 +8,8 @@ import { PlusIcon } from "lucide-react"; import { type JSX, useEffect, useMemo, useRef, useState } from "react"; import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; +import { TI18nString } from "@formbricks/types/i18n"; import { - TI18nString, TShuffleOption, TSurvey, TSurveyMultipleChoiceQuestion, diff --git a/apps/web/modules/survey/editor/components/question-card.tsx b/apps/web/modules/survey/editor/components/question-card.tsx index 95839eafec..13585920bc 100644 --- a/apps/web/modules/survey/editor/components/question-card.tsx +++ b/apps/web/modules/survey/editor/components/question-card.tsx @@ -8,8 +8,8 @@ import * as Collapsible from "@radix-ui/react-collapsible"; import { ChevronDownIcon, ChevronRightIcon, GripIcon } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { TI18nString } from "@formbricks/types/i18n"; import { - TI18nString, TSurvey, TSurveyQuestion, TSurveyQuestionId, diff --git a/apps/web/modules/survey/editor/components/question-option-choice.tsx b/apps/web/modules/survey/editor/components/question-option-choice.tsx index e83a49743f..798622f753 100644 --- a/apps/web/modules/survey/editor/components/question-option-choice.tsx +++ b/apps/web/modules/survey/editor/components/question-option-choice.tsx @@ -4,8 +4,8 @@ import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { GripVerticalIcon, PlusIcon, TrashIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { TI18nString } from "@formbricks/types/i18n"; import { - TI18nString, TSurvey, TSurveyLanguage, TSurveyMultipleChoiceQuestion, diff --git a/apps/web/modules/survey/editor/components/questions-view.tsx b/apps/web/modules/survey/editor/components/questions-view.tsx index 149078a447..aa7f30a642 100644 --- a/apps/web/modules/survey/editor/components/questions-view.tsx +++ b/apps/web/modules/survey/editor/components/questions-view.tsx @@ -16,9 +16,8 @@ import React, { SetStateAction, useEffect, useMemo } from "react"; import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; import { TSurveyQuota } from "@formbricks/types/quota"; +import { type TConditionGroup, type TSingleCondition } from "@formbricks/types/surveys/logic"; import { - TConditionGroup, - TSingleCondition, TSurvey, TSurveyLogic, TSurveyLogicAction, diff --git a/apps/web/modules/survey/editor/components/ranking-question-form.tsx b/apps/web/modules/survey/editor/components/ranking-question-form.tsx index f182c7d8d5..f79c8c5396 100644 --- a/apps/web/modules/survey/editor/components/ranking-question-form.tsx +++ b/apps/web/modules/survey/editor/components/ranking-question-form.tsx @@ -7,7 +7,8 @@ import { createId } from "@paralleldrive/cuid2"; import { PlusIcon } from "lucide-react"; import { type JSX, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { TI18nString, TSurvey, TSurveyRankingQuestion } from "@formbricks/types/surveys/types"; +import { TI18nString } from "@formbricks/types/i18n"; +import { TSurvey, TSurveyRankingQuestion } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils"; import { QuestionFormInput } from "@/modules/survey/components/question-form-input"; diff --git a/apps/web/modules/survey/editor/lib/logic-rule-engine.test.ts b/apps/web/modules/survey/editor/lib/logic-rule-engine.test.ts index 432fabfc07..9cec67ee6f 100644 --- a/apps/web/modules/survey/editor/lib/logic-rule-engine.test.ts +++ b/apps/web/modules/survey/editor/lib/logic-rule-engine.test.ts @@ -1,6 +1,7 @@ -import { TFunction } from "react-i18next"; +import { TFunction } from "i18next"; import { describe, expect, test, vi } from "vitest"; -import { TSurveyQuestionTypeEnum, ZSurveyLogicConditionsOperator } from "@formbricks/types/surveys/types"; +import { ZSurveyLogicConditionsOperator } from "@formbricks/types/surveys/logic"; +import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; import { TLogicRuleOption, getLogicRules } from "./logic-rule-engine"; // Mock the translation function diff --git a/apps/web/modules/survey/editor/lib/logic-rule-engine.ts b/apps/web/modules/survey/editor/lib/logic-rule-engine.ts index ef9b28ba64..682a0ac3f4 100644 --- a/apps/web/modules/survey/editor/lib/logic-rule-engine.ts +++ b/apps/web/modules/survey/editor/lib/logic-rule-engine.ts @@ -1,5 +1,6 @@ import { TFunction } from "i18next"; -import { TSurveyQuestionTypeEnum, ZSurveyLogicConditionsOperator } from "@formbricks/types/surveys/types"; +import { ZSurveyLogicConditionsOperator } from "@formbricks/types/surveys/logic"; +import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; export const getLogicRules = (t: TFunction) => { return { diff --git a/apps/web/modules/survey/editor/lib/shared-conditions-factory.test.ts b/apps/web/modules/survey/editor/lib/shared-conditions-factory.test.ts index 723f654e36..5a211a87c6 100644 --- a/apps/web/modules/survey/editor/lib/shared-conditions-factory.test.ts +++ b/apps/web/modules/survey/editor/lib/shared-conditions-factory.test.ts @@ -4,10 +4,9 @@ import { TSurveyQuotaLogic } from "@formbricks/types/quota"; import { TConditionGroup, TSingleCondition, - TSurvey, TSurveyLogicConditionsOperator, - TSurveyQuestionTypeEnum, -} from "@formbricks/types/surveys/types"; +} from "@formbricks/types/surveys/logic"; +import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; import { ConditionsUpdateCallbacks, SharedConditionsFactoryParams, diff --git a/apps/web/modules/survey/editor/lib/shared-conditions-factory.ts b/apps/web/modules/survey/editor/lib/shared-conditions-factory.ts index 8cdd4df15f..8e1b998ae1 100644 --- a/apps/web/modules/survey/editor/lib/shared-conditions-factory.ts +++ b/apps/web/modules/survey/editor/lib/shared-conditions-factory.ts @@ -4,10 +4,9 @@ import { TSurveyQuotaLogic } from "@formbricks/types/quota"; import { TConditionGroup, TSingleCondition, - TSurvey, TSurveyLogicConditionsOperator, - TSurveyQuestionTypeEnum, -} from "@formbricks/types/surveys/types"; +} from "@formbricks/types/surveys/logic"; +import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; import { addConditionBelow, createGroupFromResource, diff --git a/apps/web/modules/survey/editor/lib/utils.tsx b/apps/web/modules/survey/editor/lib/utils.tsx index ebf11dda9e..13e343e785 100644 --- a/apps/web/modules/survey/editor/lib/utils.tsx +++ b/apps/web/modules/survey/editor/lib/utils.tsx @@ -1,19 +1,21 @@ import { TFunction } from "i18next"; import { EyeOffIcon, FileDigitIcon, FileType2Icon } from "lucide-react"; import { HTMLInputTypeAttribute, JSX } from "react"; +import { TI18nString } from "@formbricks/types/i18n"; import { TSurveyQuota } from "@formbricks/types/quota"; import { TConditionGroup, - TI18nString, TLeftOperand, TRightOperand, TSingleCondition, + TSurveyLogicConditionsOperator, +} from "@formbricks/types/surveys/logic"; +import { TSurvey, TSurveyEndings, TSurveyLogic, TSurveyLogicAction, TSurveyLogicActions, - TSurveyLogicConditionsOperator, TSurveyQuestion, TSurveyQuestionId, TSurveyQuestionTypeEnum, diff --git a/apps/web/modules/survey/editor/lib/validation.test.ts b/apps/web/modules/survey/editor/lib/validation.test.ts index e8243c0c13..8ea3933f6d 100644 --- a/apps/web/modules/survey/editor/lib/validation.test.ts +++ b/apps/web/modules/survey/editor/lib/validation.test.ts @@ -1,9 +1,9 @@ import { TFunction } from "i18next"; import { toast } from "react-hot-toast"; import { beforeEach, describe, expect, test, vi } from "vitest"; +import { TI18nString } from "@formbricks/types/i18n"; import { ZSegmentFilters } from "@formbricks/types/segment"; import { - TI18nString, TSurvey, TSurveyConsentQuestion, TSurveyEndScreenCard, diff --git a/apps/web/modules/survey/editor/lib/validation.ts b/apps/web/modules/survey/editor/lib/validation.ts index 2b17e5a772..58fd39f3ce 100644 --- a/apps/web/modules/survey/editor/lib/validation.ts +++ b/apps/web/modules/survey/editor/lib/validation.ts @@ -2,9 +2,9 @@ import { TFunction } from "i18next"; import { toast } from "react-hot-toast"; import { z } from "zod"; +import { TI18nString } from "@formbricks/types/i18n"; import { ZSegmentFilters } from "@formbricks/types/segment"; import { - TI18nString, TInputFieldConfig, TSurvey, TSurveyAddressQuestion, 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/apps/web/modules/ui/components/conditions-editor/types.ts b/apps/web/modules/ui/components/conditions-editor/types.ts index 242034870d..155476dc16 100644 --- a/apps/web/modules/ui/components/conditions-editor/types.ts +++ b/apps/web/modules/ui/components/conditions-editor/types.ts @@ -1,4 +1,4 @@ -import { TConnector } from "@formbricks/types/surveys/types"; +import { TConnector } from "@formbricks/types/surveys/logic"; import { TComboboxGroupedOption, TComboboxOption } from "@/modules/ui/components/input-combo-box"; export interface TGenericCondition { diff --git a/apps/web/modules/ui/components/question-toggle-table/index.tsx b/apps/web/modules/ui/components/question-toggle-table/index.tsx index a38a9bcd68..e6d06dbd67 100644 --- a/apps/web/modules/ui/components/question-toggle-table/index.tsx +++ b/apps/web/modules/ui/components/question-toggle-table/index.tsx @@ -1,12 +1,8 @@ "use client"; import { useTranslation } from "react-i18next"; -import { - TI18nString, - TSurvey, - TSurveyAddressQuestion, - TSurveyContactInfoQuestion, -} from "@formbricks/types/surveys/types"; +import { TI18nString } from "@formbricks/types/i18n"; +import { TSurvey, TSurveyAddressQuestion, TSurveyContactInfoQuestion } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { QuestionFormInput } from "@/modules/survey/components/question-form-input"; import { Switch } from "@/modules/ui/components/switch"; 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/surveys/src/components/general/welcome-card.tsx b/packages/surveys/src/components/general/welcome-card.tsx index 9e03cd911b..0d34b03484 100644 --- a/packages/surveys/src/components/general/welcome-card.tsx +++ b/packages/surveys/src/components/general/welcome-card.tsx @@ -1,8 +1,8 @@ import { useEffect } from "preact/hooks"; import { useTranslation } from "react-i18next"; +import { type TI18nString } from "@formbricks/types/i18n"; import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js"; import { type TResponseData, type TResponseTtc, type TResponseVariables } from "@formbricks/types/responses"; -import { type TI18nString } from "@formbricks/types/surveys/types"; import { SubmitButton } from "@/components/buttons/submit-button"; import { ScrollableContainer } from "@/components/wrappers/scrollable-container"; import { getLocalizedValue } from "@/lib/i18n"; diff --git a/packages/surveys/src/lib/i18n.test.ts b/packages/surveys/src/lib/i18n.test.ts index 1edf7ea314..e510425675 100644 --- a/packages/surveys/src/lib/i18n.test.ts +++ b/packages/surveys/src/lib/i18n.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { TI18nString } from "@formbricks/types/surveys/types"; +import { TI18nString } from "@formbricks/types/i18n"; import { getLocalizedValue } from "./i18n"; describe("i18n", () => { diff --git a/packages/surveys/src/lib/i18n.ts b/packages/surveys/src/lib/i18n.ts index 9bab640b33..b4ee9fd235 100644 --- a/packages/surveys/src/lib/i18n.ts +++ b/packages/surveys/src/lib/i18n.ts @@ -1,4 +1,4 @@ -import { TI18nString } from "@formbricks/types/surveys/types"; +import { TI18nString } from "@formbricks/types/i18n"; // Type guard to check if an object is an I18nString const isI18nObject = (obj: any): obj is TI18nString => { diff --git a/packages/surveys/src/lib/logic.test.ts b/packages/surveys/src/lib/logic.test.ts index 558c7ce088..c36a7648dc 100644 --- a/packages/surveys/src/lib/logic.test.ts +++ b/packages/surveys/src/lib/logic.test.ts @@ -1,9 +1,8 @@ import { describe, expect, test, vi } from "vitest"; import { TJsEnvironmentStateSurvey } from "@formbricks/types/js"; import { TResponseData, TResponseVariables } from "@formbricks/types/responses"; +import { TConditionGroup, TSingleCondition } from "@formbricks/types/surveys/logic"; import { - TConditionGroup, - TSingleCondition, TSurveyLogicAction, TSurveyQuestionTypeEnum, TSurveyVariable, diff --git a/packages/surveys/src/lib/logic.ts b/packages/surveys/src/lib/logic.ts index 5beb2e9414..c6fc68b92c 100644 --- a/packages/surveys/src/lib/logic.ts +++ b/packages/surveys/src/lib/logic.ts @@ -1,9 +1,8 @@ import { TJsEnvironmentStateSurvey } from "@formbricks/types/js"; import { TResponseData, TResponseVariables } from "@formbricks/types/responses"; +import { TConditionGroup, TSingleCondition } from "@formbricks/types/surveys/logic"; import { TActionCalculate, - TConditionGroup, - TSingleCondition, TSurveyLogicAction, TSurveyQuestion, TSurveyQuestionTypeEnum, diff --git a/packages/types/i18n.ts b/packages/types/i18n.ts new file mode 100644 index 0000000000..183b8f681d --- /dev/null +++ b/packages/types/i18n.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZI18nString = z.record(z.string()).refine((obj) => "default" in obj, { + message: "I18n string must have a 'default' key", +}); + +export type TI18nString = z.infer; diff --git a/packages/types/quota.ts b/packages/types/quota.ts index 7466f669a2..92fe35775c 100644 --- a/packages/types/quota.ts +++ b/packages/types/quota.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { ZId } from "./common"; import type { TResponse } from "./responses"; -import { ZConnector, ZSingleCondition } from "./surveys/types"; +import { ZConnector, ZSingleCondition } from "./surveys/logic"; // Complete quota conditions structure export const ZSurveyQuotaLogic = z.object({ diff --git a/packages/types/surveys/blocks-validation.ts b/packages/types/surveys/blocks-validation.ts new file mode 100644 index 0000000000..085e4a4614 --- /dev/null +++ b/packages/types/surveys/blocks-validation.ts @@ -0,0 +1,76 @@ +import type { TActionJumpToBlock, TSurveyBlock, TSurveyBlockLogicAction } from "./blocks"; + +export const findBlocksWithCyclicLogic = (blocks: TSurveyBlock[]): string[] => { + const visited: Record = {}; + const recStack: Record = {}; + const cyclicBlocks = new Set(); + + const checkForCyclicLogic = (blockId: string): 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); + recStack[blockId] = false; + return true; + } else if (recStack[destination]) { + cyclicBlocks.add(blockId); + recStack[blockId] = false; + return true; + } + } + } + } + + // Check fallback logic + if (block?.logicFallback) { + const fallbackBlockId = block.logicFallback; + if (!visited[fallbackBlockId] && checkForCyclicLogic(fallbackBlockId)) { + cyclicBlocks.add(blockId); + recStack[blockId] = false; + return true; + } else if (recStack[fallbackBlockId]) { + cyclicBlocks.add(blockId); + recStack[blockId] = false; + 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) { + if (!visited[nextBlock.id] && checkForCyclicLogic(nextBlock.id)) { + cyclicBlocks.add(blockId); + recStack[blockId] = false; + return true; + } else if (recStack[nextBlock.id]) { + cyclicBlocks.add(blockId); + recStack[blockId] = false; + 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..550d255acd --- /dev/null +++ b/packages/types/surveys/blocks.ts @@ -0,0 +1,123 @@ +import { z } from "zod"; +import { ZId } from "../common"; +import { ZI18nString } from "../i18n"; +import { ZSurveyElementId, ZSurveyElements } from "./elements"; +import { + ZActionNumberVariableCalculateOperator, + ZActionTextVariableCalculateOperator, + ZConditionGroup, + ZDynamicLogicFieldValue, +} from "./logic"; + +export const ZSurveyBlockId = ZId; + +// 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, +}); + +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(), + }) + .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..ecaa1fbda2 --- /dev/null +++ b/packages/types/surveys/elements-validation.ts @@ -0,0 +1,118 @@ +import { z } from "zod"; +import type { TI18nString } from "../i18n"; +import type { 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]) + .filter((text): text is string => typeof text === "string" && text.trim().length > 0) + .map((text) => text.trim()); + 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..79342c007c --- /dev/null +++ b/packages/types/surveys/elements.ts @@ -0,0 +1,308 @@ +import { z } from "zod"; +import { ZUrl } from "../common"; +import { ZI18nString } from "../i18n"; +import { ZAllowedFileExtension } from "../storage"; +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.nativeEnum(TSurveyElementTypeEnum), + headline: ZI18nString, + subheader: ZI18nString.optional(), + imageUrl: ZUrl.optional(), + videoUrl: ZUrl.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/logic.ts b/packages/types/surveys/logic.ts new file mode 100644 index 0000000000..164ed8a54a --- /dev/null +++ b/packages/types/surveys/logic.ts @@ -0,0 +1,163 @@ +import { z } from "zod"; +import { ZId } from "../common"; + +// Logic operators +export const ZSurveyLogicConditionsOperator = z.enum([ + "equals", + "doesNotEqual", + "contains", + "doesNotContain", + "startsWith", + "doesNotStartWith", + "endsWith", + "doesNotEndWith", + "isSubmitted", + "isSkipped", + "isGreaterThan", + "isLessThan", + "isGreaterThanOrEqual", + "isLessThanOrEqual", + "equalsOneOf", + "includesAllOf", + "includesOneOf", + "doesNotIncludeOneOf", + "doesNotIncludeAllOf", + "isClicked", + "isAccepted", + "isBefore", + "isAfter", + "isBooked", + "isPartiallySubmitted", + "isCompletelySubmitted", + "isSet", + "isNotSet", + "isEmpty", + "isNotEmpty", + "isAnyOf", +]); + +export type TSurveyLogicConditionsOperator = z.infer; + +// Variable calculate operators +export const ZActionTextVariableCalculateOperator = z.enum(["assign", "concat"], { + message: "Conditional Logic: Invalid operator for a text variable", +}); + +export const ZActionNumberVariableCalculateOperator = z.enum( + ["add", "subtract", "multiply", "divide", "assign"], + { message: "Conditional Logic: Invalid operator for a number variable" } +); + +export type TActionTextVariableCalculateOperator = z.infer; +export type TActionNumberVariableCalculateOperator = z.infer; + +// Connector +export const ZConnector = z.enum(["and", "or"]); +export type TConnector = z.infer; + +// Dynamic field types for conditions +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"), +}); + +export const ZDynamicLogicFieldValue = z.union([ZDynamicQuestion, ZDynamicVariable, ZDynamicHiddenField], { + message: "Conditional Logic: Invalid dynamic field value", +}); + +export type TDynamicLogicFieldValue = z.infer; + +// Right operand for conditions +export const ZRightOperandStatic = z.object({ + type: z.literal("static"), + value: z.union([z.string(), z.number(), z.array(z.string())]), +}); + +const _ZLeftOperand = ZDynamicLogicFieldValue; +export type TLeftOperand = z.infer; + +export const ZRightOperand = z.union([ZRightOperandStatic, ZDynamicLogicFieldValue]); +export type TRightOperand = z.infer; + +// Operators that don't require a right operand +export const operatorsWithoutRightOperand = [ + 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, +] as const; + +// Single condition +export const ZSingleCondition = z + .object({ + id: ZId, + leftOperand: ZDynamicLogicFieldValue, + operator: ZSurveyLogicConditionsOperator, + rightOperand: ZRightOperand.optional(), + }) + .and( + z.object({ + connector: z.undefined(), + }) + ) + .superRefine((val, ctx) => { + if ( + !operatorsWithoutRightOperand.includes(val.operator as (typeof operatorsWithoutRightOperand)[number]) + ) { + 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 const ZConditionGroup: z.ZodType = z.lazy(() => + z.object({ + id: ZId, + connector: ZConnector, + conditions: z.array(z.union([ZSingleCondition, ZConditionGroup])), + }) +); + +export interface TConditionGroup { + id: string; + connector: TConnector; + conditions: (TSingleCondition | TConditionGroup)[]; +} diff --git a/packages/types/surveys/types.ts b/packages/types/surveys/types.ts index 8313fcdc51..2465f99fc5 100644 --- a/packages/types/surveys/types.ts +++ b/packages/types/surveys/types.ts @@ -3,25 +3,34 @@ import { ZSurveyFollowUp } from "@formbricks/database/types/survey-follow-up"; import { ZActionClass, ZActionClassNoCodeConfig } from "../action-classes"; import { ZColor, ZId, ZPlacement, getZSafeUrl } from "../common"; import { ZContactAttributes } from "../contact-attribute"; +import { type TI18nString, ZI18nString } from "../i18n"; 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 { + type TConditionGroup, + type TSingleCondition, + type TSurveyLogicConditionsOperator, + ZActionNumberVariableCalculateOperator, + ZActionTextVariableCalculateOperator, + ZConditionGroup, + ZDynamicLogicFieldValue, +} from "./logic"; import { FORBIDDEN_IDS, findLanguageCodesForDuplicateLabels, findQuestionsWithCyclicLogic, + getTextContent, isConditionGroup, validateCardFieldsForAllLanguages, validateQuestionLabels, } from "./validation"; -export const ZI18nString = z.record(z.string()).refine((obj) => "default" in obj, { - message: "Object must have a 'default' key", -}); - -export type TI18nString = z.infer; - const ZSurveyEndingBase = z.object({ id: z.string().cuid2(), }); @@ -250,161 +259,12 @@ export type TSurveyPictureChoice = z.infer; export type TSurveyQuestionChoice = z.infer; -// Logic types -export const ZSurveyLogicConditionsOperator = z.enum([ - "equals", - "doesNotEqual", - "contains", - "doesNotContain", - "startsWith", - "doesNotStartWith", - "endsWith", - "doesNotEndWith", - "isSubmitted", - "isSkipped", - "isGreaterThan", - "isLessThan", - "isGreaterThanOrEqual", - "isLessThanOrEqual", - "equalsOneOf", - "includesAllOf", - "includesOneOf", - "doesNotIncludeOneOf", - "doesNotIncludeAllOf", - "isClicked", - "isAccepted", - "isBefore", - "isAfter", - "isBooked", - "isPartiallySubmitted", - "isCompletelySubmitted", - "isSet", - "isNotSet", - "isEmpty", - "isNotEmpty", - "isAnyOf", -]); - -const operatorsWithoutRightOperand = [ - 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, -] as const; - +// Actions for question logic export const ZDynamicLogicField = z.enum(["question", "variable", "hiddenField"]); export const ZActionObjective = z.enum(["calculate", "requireAnswer", "jumpToQuestion"]); -export const ZActionTextVariableCalculateOperator = z.enum(["assign", "concat"], { - message: "Conditional Logic: Invalid operator for a text variable", -}); -export const ZActionNumberVariableCalculateOperator = z.enum( - ["add", "subtract", "multiply", "divide", "assign"], - { message: "Conditional Logic: Invalid operator for a number variable" } -); -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", -}); - -export type TSurveyLogicConditionsOperator = z.infer; export type TDynamicLogicField = z.infer; export type TActionObjective = z.infer; -export type TActionTextVariableCalculateOperator = z.infer; -export type TActionNumberVariableCalculateOperator = z.infer; - -// Conditions -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; - -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 as (typeof operatorsWithoutRightOperand)[number]) - ) { - 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 const ZConnector = z.enum(["and", "or"]); -export type TConnector = z.infer; - -export interface TConditionGroup { - id: string; - connector: TConnector; - conditions: (TSingleCondition | TConditionGroup)[]; -} - -const ZConditionGroup: z.ZodType = z.lazy(() => - z.object({ - id: ZId, - connector: ZConnector, - conditions: z.array(z.union([ZSingleCondition, ZConditionGroup])), - }) -); // Actions export const ZActionVariableValueType = z.union([z.literal("static"), ZDynamicLogicField]); @@ -811,6 +671,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 +730,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 +795,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 +2763,682 @@ 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", + "isLessThan", + "isLessThanOrEqual", + "isGreaterThan", + "isGreaterThanOrEqual", + "isSubmitted", + "isSkipped", + ].includes(operator) + ) { + isInvalidOperator = true; + } + break; + } + break; + case TSurveyElementTypeEnum.MultipleChoiceSingle: + if (!["equals", "doesNotEqual", "isSubmitted", "isSkipped"].includes(operator)) { + isInvalidOperator = true; + } + break; + case TSurveyElementTypeEnum.MultipleChoiceMulti: + case TSurveyElementTypeEnum.PictureSelection: + case TSurveyElementTypeEnum.Ranking: + if ( + ![ + "equals", + "doesNotEqual", + "includesAllOf", + "includesOneOf", + "doesNotIncludeAllOf", + "doesNotIncludeOneOf", + "isSubmitted", + "isSkipped", + ].includes(operator) + ) { + isInvalidOperator = true; + } + break; + case TSurveyElementTypeEnum.NPS: + case TSurveyElementTypeEnum.Rating: + if ( + ![ + "equals", + "doesNotEqual", + "isLessThan", + "isLessThanOrEqual", + "isGreaterThan", + "isGreaterThanOrEqual", + "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.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; + } + + 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 { + // Validate that the choice ID exists in the element's choices + const choiceMatch = element.choices.find((c) => c.id === rightOperand.value); + if (!choiceMatch) { + issues.push({ + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Choice "${rightOperand.value}" does not exist in element ${String(elementInfo.element + 1)} of block ${String(elementInfo.block + 1)}`, + path: ["blocks", blockIndex, "logic", logicIndex, "conditions"], + }); + } + } + } + } else if ( + element.type === TSurveyElementTypeEnum.MultipleChoiceMulti || + element.type === TSurveyElementTypeEnum.PictureSelection || + element.type === TSurveyElementTypeEnum.Ranking + ) { + 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 { + // Validate that the choice ID exists in the element's choices + const choiceMatch = element.choices.find((c) => c.id === rightOperand.value); + if (!choiceMatch) { + issues.push({ + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Choice "${rightOperand.value}" does not exist in element ${String(elementInfo.element + 1)} of block ${String(elementInfo.block + 1)}`, + path: ["blocks", blockIndex, "logic", logicIndex, "conditions"], + }); + } + } + } else if ( + ["includesAllOf", "includesOneOf", "doesNotIncludeAllOf", "doesNotIncludeOneOf"].includes( + condition.operator + ) + ) { + if (!Array.isArray(rightOperand.value)) { + issues.push({ + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Right operand should be an array for "${operator}" in logic no: ${String(logicIndex + 1)} of block ${String(blockIndex + 1)}`, + path: ["blocks", blockIndex, "logic", logicIndex, "conditions"], + }); + } else { + rightOperand.value.forEach((value) => { + if (typeof value !== "string") { + issues.push({ + code: z.ZodIssueCode.custom, + message: `Conditional Logic: Each value in the 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"], + }); + } + }); + + // Validate that all choice IDs exist in the element's choices + const choiceIds = element.choices.map((c) => c.id); + if (rightOperand.value.some((value) => !choiceIds.includes(value))) { + issues.push({ + code: z.ZodIssueCode.custom, + message: `Conditional Logic: One or more choices selected in right operand do not exist in the element 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 +3487,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 +3513,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..569187fbfd 100644 --- a/packages/types/surveys/validation.ts +++ b/packages/types/surveys/validation.ts @@ -1,10 +1,9 @@ import { parse } from "node-html-parser"; import { z } from "zod"; +import type { TI18nString } from "../i18n"; +import type { TConditionGroup, TSingleCondition } from "./logic"; import type { TActionJumpToQuestion, - TConditionGroup, - TI18nString, - TSingleCondition, TSurveyLanguage, TSurveyLogicAction, TSurveyQuestion, @@ -299,22 +298,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)) {