feat: add blocks model to support multi-question pages (schema only) (#6754)

This commit is contained in:
Anshuman Pandey
2025-10-31 11:52:35 +05:30
committed by GitHub
62 changed files with 2366 additions and 523 deletions

View File

@@ -1,8 +1,8 @@
"use client";
import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n";
import {
TI18nString,
TSurvey,
TSurveyQuestionId,
TSurveyQuestionSummaryConsent,

View File

@@ -1,8 +1,8 @@
"use client";
import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n";
import {
TI18nString,
TSurvey,
TSurveyQuestionId,
TSurveyQuestionSummaryMatrix,

View File

@@ -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,

View File

@@ -1,8 +1,8 @@
"use client";
import { useTranslation } from "react-i18next";
import { type TI18nString } from "@formbricks/types/i18n";
import {
TI18nString,
TSurvey,
TSurveyQuestionId,
TSurveyQuestionSummaryNps,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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";

View File

@@ -3668,6 +3668,7 @@ export const previewSurvey = (projectName: string, t: TFunction) => {
isDraft: true,
},
],
blocks: [],
endings: [
{
id: "cltyqp5ng000108l9dmxw6nde",

View File

@@ -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

View File

@@ -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.

View File

@@ -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,
};

View File

@@ -37,6 +37,7 @@ export const selectSurvey = {
status: true,
welcomeCard: true,
questions: true,
blocks: true,
endings: true,
hiddenFields: true,
variables: true,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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";

View File

@@ -33,6 +33,7 @@ export const ZSurveyInput = ZSurveyWithoutQuestionType.pick({
type: true,
environmentId: true,
questions: true,
blocks: true,
endings: true,
hiddenFields: true,
variables: true,

View File

@@ -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";

View File

@@ -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";

View File

@@ -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,

View File

@@ -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("");
});

View File

@@ -1,6 +1,6 @@
import { TFunction } from "i18next";
import { type TI18nString } from "@formbricks/types/i18n";
import {
TI18nString,
TSurvey,
TSurveyMatrixQuestion,
TSurveyMultipleChoiceQuestion,

View File

@@ -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,

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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";

View File

@@ -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

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -16,6 +16,7 @@ export const selectSurvey = {
status: true,
welcomeCard: true,
questions: true,
blocks: true,
endings: true,
hiddenFields: true,
variables: true,

View File

@@ -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,

View File

@@ -249,6 +249,7 @@ const getExistingSurvey = async (surveyId: string) => {
},
welcomeCard: true,
questions: true,
blocks: true,
endings: true,
variables: true,
hiddenFields: true,

View File

@@ -18,6 +18,7 @@ export const getMinimalSurvey = (t: TFunction): TSurvey => ({
displayLimit: null,
welcomeCard: getDefaultWelcomeCard(t),
questions: [],
blocks: [],
endings: [getDefaultEndingCard([], t)],
hiddenFields: {
enabled: false,

View File

@@ -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 {

View File

@@ -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";

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."Survey" ADD COLUMN "blocks" JSONB[] DEFAULT ARRAY[]::JSONB[];

View File

@@ -340,6 +340,8 @@ model Survey {
welcomeCard Json @default("{\"enabled\": false}")
/// [SurveyQuestions]
questions Json @default("[]")
/// [SurveyBlocks]
blocks Json[] @default([])
/// [SurveyEnding]
endings Json[] @default([])
/// [SurveyHiddenFields]

View File

@@ -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",
}),

View File

@@ -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";

View File

@@ -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", () => {

View File

@@ -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 => {

View File

@@ -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,

View File

@@ -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,

7
packages/types/i18n.ts Normal file
View File

@@ -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<typeof ZI18nString>;

View File

@@ -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({

View File

@@ -0,0 +1,76 @@
import type { TActionJumpToBlock, TSurveyBlock, TSurveyBlockLogicAction } from "./blocks";
export const findBlocksWithCyclicLogic = (blocks: TSurveyBlock[]): string[] => {
const visited: Record<string, boolean> = {};
const recStack: Record<string, boolean> = {};
const cyclicBlocks = new Set<string>();
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");
};

View File

@@ -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<typeof ZActionCalculate>;
// RequireAnswer action - targets element IDs
export const ZActionRequireAnswer = z.object({
id: ZId,
objective: z.literal("requireAnswer"),
target: ZSurveyElementId,
});
export type TActionRequireAnswer = z.infer<typeof ZActionRequireAnswer>;
// 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<typeof ZActionJumpToBlock>;
// Block logic actions
export const ZSurveyBlockLogicAction = z.union([ZActionCalculate, ZActionRequireAnswer, ZActionJumpToBlock]);
export type TSurveyBlockLogicAction = z.infer<typeof ZSurveyBlockLogicAction>;
const ZSurveyBlockLogicActions = z.array(ZSurveyBlockLogicAction);
export type TSurveyBlockLogicActions = z.infer<typeof ZSurveyBlockLogicActions>;
// Block Logic
export const ZSurveyBlockLogic = z.object({
id: ZId,
conditions: ZConditionGroup,
actions: ZSurveyBlockLogicActions,
});
export type TSurveyBlockLogic = z.infer<typeof ZSurveyBlockLogic>;
// 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<typeof ZSurveyBlock>;
export const ZSurveyBlocks = z.array(ZSurveyBlock);
export type TSurveyBlocks = z.infer<typeof ZSurveyBlocks>;

View File

@@ -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<string, string> = {
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<string>();
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);
};

View File

@@ -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<typeof ZSurveyElementId>;
// 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<typeof ZSurveyOpenTextElementInputType>;
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<typeof ZSurveyOpenTextElement>;
// Consent Element
export const ZSurveyConsentElement = ZSurveyElementBase.extend({
type: z.literal(TSurveyElementTypeEnum.Consent),
label: ZI18nString,
});
export type TSurveyConsentElement = z.infer<typeof ZSurveyConsentElement>;
// 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<typeof ZShuffleOption>;
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<typeof ZSurveyMultipleChoiceElement>;
// 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<typeof ZSurveyNPSElement>;
// 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<typeof ZSurveyCTAElement>;
// 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<typeof ZSurveyRatingElement>;
// Picture Selection Element
export const ZSurveyPictureChoice = z.object({
id: z.string(),
imageUrl: z.string(),
});
export type TSurveyPictureChoice = z.infer<typeof ZSurveyPictureChoice>;
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<typeof ZSurveyPictureSelectionElement>;
// 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<typeof ZSurveyDateElement>;
// 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<typeof ZSurveyFileUploadElement>;
// 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<typeof ZSurveyCalElement>;
// Matrix Element
export const ZSurveyMatrixElementChoice = z.object({
id: z.string(),
label: ZI18nString,
});
export type TSurveyMatrixElementChoice = z.infer<typeof ZSurveyMatrixElementChoice>;
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<typeof ZSurveyMatrixElement>;
// Address Element
const ZToggleInputConfig = z.object({
show: z.boolean(),
required: z.boolean(),
placeholder: ZI18nString,
});
export type TInputFieldConfig = z.infer<typeof ZToggleInputConfig>;
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<typeof ZSurveyAddressElement>;
// 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<typeof ZSurveyRankingElement>;
// 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<typeof ZSurveyContactInfoElement>;
// 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<typeof ZSurveyElement>;
export const ZSurveyElements = z.array(ZSurveyElement);
export type TSurveyElements = z.infer<typeof ZSurveyElements>;

View File

@@ -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<typeof ZSurveyLogicConditionsOperator>;
// 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<typeof ZActionTextVariableCalculateOperator>;
export type TActionNumberVariableCalculateOperator = z.infer<typeof ZActionNumberVariableCalculateOperator>;
// Connector
export const ZConnector = z.enum(["and", "or"]);
export type TConnector = z.infer<typeof ZConnector>;
// 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<typeof ZDynamicLogicFieldValue>;
// 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<typeof _ZLeftOperand>;
export const ZRightOperand = z.union([ZRightOperandStatic, ZDynamicLogicFieldValue]);
export type TRightOperand = z.infer<typeof ZRightOperand>;
// 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<typeof ZSingleCondition>;
export const ZConditionGroup: z.ZodType<TConditionGroup> = 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)[];
}

File diff suppressed because it is too large Load Diff

View File

@@ -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)) {