From 22ad78a18762df136b5e5ae0daa75ef891e66ebc Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Thu, 20 Nov 2025 17:02:12 +0530 Subject: [PATCH 01/20] fixes feedback --- .../(analysis)/summary/lib/surveySummary.ts | 4 +- .../components/question-form-input/index.tsx | 13 ++-- .../add-question-to-block-button.tsx | 2 +- .../components/question-option-choice.tsx | 10 ++- .../survey/link/components/link-survey.tsx | 12 ++-- packages/js-core/src/types/survey.ts | 1 - .../components/general/block-conditional.tsx | 2 - .../general/element-conditional.tsx | 28 ++++++-- .../surveys/src/components/general/survey.tsx | 20 +----- .../src/components/general/welcome-card.tsx | 4 +- .../components/questions/ranking-question.tsx | 11 ++-- packages/surveys/src/lib/logic.ts | 6 +- packages/surveys/src/lib/utils.test.ts | 20 +++--- packages/surveys/src/lib/utils.ts | 64 ++++--------------- packages/types/formbricks-surveys.ts | 1 - packages/types/surveys/elements.ts | 2 + 16 files changed, 84 insertions(+), 116 deletions(-) diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts index 36c7a386f3..12cb17c302 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts @@ -18,6 +18,7 @@ import { TSurveyAddressElement, TSurveyContactInfoElement, TSurveyElement, + TSurveyElementChoice, TSurveyElementTypeEnum, } from "@formbricks/types/surveys/elements"; import { @@ -33,7 +34,6 @@ import { TSurveyElementSummaryRanking, TSurveyElementSummaryRating, TSurveyLanguage, - TSurveyQuestionChoice, TSurveySummary, } from "@formbricks/types/surveys/types"; import { getTextContent } from "@formbricks/types/surveys/validation"; @@ -323,7 +323,7 @@ const checkForI18n = ( // Return the localized value of the choice fo multiSelect single question if (question && "choices" in question) { const choice = question.choices?.find( - (choice: TSurveyQuestionChoice) => choice.label?.[languageCode] === responseData[id] + (choice: TSurveyElementChoice) => choice.label?.[languageCode] === responseData[id] ); return choice && "label" in choice ? getLocalizedValue(choice.label, "default") || responseData[id] 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 13aa22f2b0..a17b3a469f 100644 --- a/apps/web/modules/survey/components/question-form-input/index.tsx +++ b/apps/web/modules/survey/components/question-form-input/index.tsx @@ -6,13 +6,12 @@ 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 { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { - TSurvey, - TSurveyEndScreenCard, - TSurveyQuestionChoice, - TSurveyRedirectUrlCard, -} from "@formbricks/types/surveys/types"; + TSurveyElement, + TSurveyElementChoice, + TSurveyElementTypeEnum, +} from "@formbricks/types/surveys/elements"; +import { TSurvey, TSurveyEndScreenCard, TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils"; import { useSyncScroll } from "@/lib/utils/hooks/useSyncScroll"; @@ -44,7 +43,7 @@ interface QuestionFormInputProps { questionIdx: number; updateQuestion?: (questionIdx: number, data: Partial) => void; updateSurvey?: (data: Partial | Partial) => void; - updateChoice?: (choiceIdx: number, data: Partial) => void; + updateChoice?: (choiceIdx: number, data: Partial) => void; updateMatrixLabel?: (index: number, type: "row" | "column", matrixLabel: TI18nString) => void; isInvalid: boolean; selectedLanguageCode: string; diff --git a/apps/web/modules/survey/editor/components/add-question-to-block-button.tsx b/apps/web/modules/survey/editor/components/add-question-to-block-button.tsx index b2a4e27b5d..e19d4f4cf0 100644 --- a/apps/web/modules/survey/editor/components/add-question-to-block-button.tsx +++ b/apps/web/modules/survey/editor/components/add-question-to-block-button.tsx @@ -1,7 +1,7 @@ "use client"; import { createId } from "@paralleldrive/cuid2"; -import { Project } from "@prisma/client"; +import { type Project } from "@prisma/client"; import { PlusIcon } from "lucide-react"; import { useState } from "react"; import toast from "react-hot-toast"; 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 6e7f4a910a..126a5cf972 100644 --- a/apps/web/modules/survey/editor/components/question-option-choice.tsx +++ b/apps/web/modules/survey/editor/components/question-option-choice.tsx @@ -5,8 +5,12 @@ 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 { TSurveyMultipleChoiceElement, TSurveyRankingElement } from "@formbricks/types/surveys/elements"; -import { TSurvey, TSurveyLanguage, TSurveyQuestionChoice } from "@formbricks/types/surveys/types"; +import { + TSurveyElementChoice, + TSurveyMultipleChoiceElement, + TSurveyRankingElement, +} from "@formbricks/types/surveys/elements"; +import { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { cn } from "@/lib/cn"; import { createI18nString } from "@/lib/i18n/utils"; @@ -16,7 +20,7 @@ import { TooltipRenderer } from "@/modules/ui/components/tooltip"; import { isLabelValidForAllLanguages } from "../lib/validation"; interface ChoiceProps { - choice: TSurveyQuestionChoice; + choice: TSurveyElementChoice; choiceIdx: number; questionIdx: number; updateChoice: (choiceIdx: number, updatedAttributes: { label: TI18nString }) => void; diff --git a/apps/web/modules/survey/link/components/link-survey.tsx b/apps/web/modules/survey/link/components/link-survey.tsx index 1006785ce6..bbb3205d7b 100644 --- a/apps/web/modules/survey/link/components/link-survey.tsx +++ b/apps/web/modules/survey/link/components/link-survey.tsx @@ -12,7 +12,7 @@ import { VerifyEmail } from "@/modules/survey/link/components/verify-email"; import { getPrefillValue } from "@/modules/survey/link/lib/utils"; import { SurveyInline } from "@/modules/ui/components/survey"; -let setQuestionId = (_: string) => {}; +let setBlockId = (_: string) => {}; let setResponseData = (_: TResponseData) => {}; interface LinkSurveyProps { @@ -158,7 +158,11 @@ export const LinkSurvey = ({ }; const handleResetSurvey = () => { - setQuestionId(survey.welcomeCard.enabled ? "start" : questions[0].id); + if (survey.welcomeCard.enabled) { + setBlockId("start"); + } else if (survey.blocks[0]) { + setBlockId(survey.blocks[0].id); + } setResponseData({}); }; @@ -191,8 +195,8 @@ export const LinkSurvey = ({ prefillResponseData={prefillValue} skipPrefilled={skipPrefilled} responseCount={responseCount} - getSetQuestionId={(f: (value: string) => void) => { - setQuestionId = f; + getSetBlockId={(f: (value: string) => void) => { + setBlockId = f; }} getSetResponseData={(f: (value: TResponseData) => void) => { setResponseData = f; diff --git a/packages/js-core/src/types/survey.ts b/packages/js-core/src/types/survey.ts index 011cb0ed14..436f196e85 100644 --- a/packages/js-core/src/types/survey.ts +++ b/packages/js-core/src/types/survey.ts @@ -8,7 +8,6 @@ export interface SurveyBaseProps { isBrandingEnabled: boolean; getSetIsError?: (getSetError: (value: boolean) => void) => void; getSetIsResponseSendingFinished?: (getSetIsResponseSendingFinished: (value: boolean) => void) => void; - getSetQuestionId?: (getSetQuestionId: (value: string) => void) => void; getSetResponseData?: (getSetResponseData: (value: TResponseData) => void) => void; onDisplay?: () => void; onResponse?: (response: TResponseUpdate) => void; diff --git a/packages/surveys/src/components/general/block-conditional.tsx b/packages/surveys/src/components/general/block-conditional.tsx index 5eea9695f6..f6dc6955fc 100644 --- a/packages/surveys/src/components/general/block-conditional.tsx +++ b/packages/surveys/src/components/general/block-conditional.tsx @@ -12,7 +12,6 @@ import { getLocalizedValue } from "@/lib/i18n"; import { cn } from "@/lib/utils"; interface BlockConditionalProps { - // survey: TJsEnvironmentStateSurvey; block: TSurveyBlock; value: TResponseData; onChange: (responseData: TResponseData) => void; @@ -36,7 +35,6 @@ interface BlockConditionalProps { } export function BlockConditional({ - // survey, block, value, onChange, diff --git a/packages/surveys/src/components/general/element-conditional.tsx b/packages/surveys/src/components/general/element-conditional.tsx index 5a8da19e86..16c14971ed 100644 --- a/packages/surveys/src/components/general/element-conditional.tsx +++ b/packages/surveys/src/components/general/element-conditional.tsx @@ -2,8 +2,11 @@ import { useEffect, useRef } from "preact/hooks"; import { type TJsFileUploadParams } from "@formbricks/types/js"; import { type TResponseData, type TResponseDataValue, type TResponseTtc } from "@formbricks/types/responses"; import { type TUploadFileConfig } from "@formbricks/types/storage"; -import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; -import { type TSurveyQuestionChoice } from "@formbricks/types/surveys/types"; +import { + TSurveyElement, + TSurveyElementChoice, + TSurveyElementTypeEnum, +} from "@formbricks/types/surveys/elements"; import { AddressQuestion } from "@/components/questions/address-question"; import { CalQuestion } from "@/components/questions/cal-question"; import { ConsentQuestion } from "@/components/questions/consent-question"; @@ -74,10 +77,7 @@ export function ElementConditional({ } }, [formRef]); - const getResponseValueForRankingQuestion = ( - value: string[], - choices: TSurveyQuestionChoice[] - ): string[] => { + const getResponseValueForRankingQuestion = (value: string[], choices: TSurveyElementChoice[]): string[] => { return value .map((entry) => { // First check if entry is already a valid choice ID @@ -87,7 +87,7 @@ export function ElementConditional({ // Otherwise, treat it as a localized label and find the choice by label return choices.find((choice) => getLocalizedValue(choice.label, languageCode) === entry)?.id; }) - .filter((id): id is TSurveyQuestionChoice["id"] => id !== undefined); + .filter((id): id is TSurveyElementChoice["id"] => id !== undefined); }; useEffect(() => { @@ -99,6 +99,20 @@ export function ElementConditional({ // eslint-disable-next-line react-hooks/exhaustive-deps -- we want to run this only once when the element renders for the first time }, []); + const isRecognizedType = Object.values(TSurveyElementTypeEnum).includes(element.type); + + useEffect(() => { + if (!isRecognizedType) { + console.warn( + `[Formbricks] Unrecognized element type "${element.type}" for element with id "${element.id}". No component will be rendered.` + ); + } + }, [element.type, element.id, isRecognizedType]); + + if (!isRecognizedType) { + return null; + } + return (
{element.type === TSurveyElementTypeEnum.OpenText ? ( diff --git a/packages/surveys/src/components/general/survey.tsx b/packages/surveys/src/components/general/survey.tsx index d4214a5227..ca5892719a 100644 --- a/packages/surveys/src/components/general/survey.tsx +++ b/packages/surveys/src/components/general/survey.tsx @@ -26,7 +26,7 @@ import { evaluateLogic, performActions } from "@/lib/logic"; import { parseRecallInformation } from "@/lib/recall"; import { ResponseQueue } from "@/lib/response-queue"; import { SurveyState } from "@/lib/survey-state"; -import { cn, findBlockByElementId, getDefaultLanguageCode, getElementsFromSurvey } from "@/lib/utils"; +import { cn, findBlockByElementId, getDefaultLanguageCode, getElementsFromSurveyBlocks } from "@/lib/utils"; import { TResponseErrorCodesEnum } from "@/types/response-error-codes"; interface VariableStackEntry { @@ -58,7 +58,6 @@ export function Survey({ languageCode, getSetIsError, getSetIsResponseSendingFinished, - getSetQuestionId, getSetBlockId, getSetResponseData, responseCount, @@ -140,7 +139,7 @@ export function Survey({ return null; }, [appUrl, environmentId, getSetIsError, getSetIsResponseSendingFinished, surveyState]); - const questions = useMemo(() => getElementsFromSurvey(localSurvey), [localSurvey]); + const questions = useMemo(() => getElementsFromSurveyBlocks(localSurvey.blocks), [localSurvey.blocks]); const originalQuestionRequiredStates = useMemo(() => { return questions.reduce>((acc, question) => { @@ -173,7 +172,7 @@ export function Survey({ const [blockId, setBlockId] = useState(() => { if (startAtQuestionId) { // If starting at a specific question, find its parent block - const startBlock = findBlockByElementId(localSurvey, startAtQuestionId); + const startBlock = findBlockByElementId(localSurvey.blocks, startAtQuestionId); return startBlock?.id || localSurvey.blocks[0]?.id; } else if (localSurvey.welcomeCard.enabled) { return "start"; @@ -312,18 +311,6 @@ export function Survey({ } }, [getSetIsError]); - useEffect(() => { - if (getSetQuestionId) { - getSetQuestionId((value: string) => { - // Convert question ID to block ID - const block = findBlockByElementId(survey, value); - if (block) { - setBlockId(block.id); - } - }); - } - }, [getSetQuestionId, survey]); - useEffect(() => { if (getSetBlockId) { getSetBlockId((value: string) => { @@ -770,7 +757,6 @@ export function Survey({ Boolean(block) && ( { - const questions = getElementsFromSurvey(survey); + const questions = getElementsFromSurveyBlocks(survey.blocks); let totalCards = questions.length; if (survey.endings.length > 0) totalCards += 1; let idx = calculateElementIdx(survey, 0, totalCards); diff --git a/packages/surveys/src/components/questions/ranking-question.tsx b/packages/surveys/src/components/questions/ranking-question.tsx index 6b50932d4c..10d0eb2673 100644 --- a/packages/surveys/src/components/questions/ranking-question.tsx +++ b/packages/surveys/src/components/questions/ranking-question.tsx @@ -2,8 +2,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; import { useCallback, useMemo, useState } from "preact/hooks"; import { useTranslation } from "react-i18next"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; -import type { TSurveyRankingElement } from "@formbricks/types/surveys/elements"; -import type { TSurveyQuestionChoice } from "@formbricks/types/surveys/types"; +import type { TSurveyElementChoice, TSurveyRankingElement } from "@formbricks/types/surveys/elements"; import { Headline } from "@/components/general/headline"; import { QuestionMedia } from "@/components/general/question-media"; import { Subheader } from "@/components/general/subheader"; @@ -55,7 +54,7 @@ export function RankingQuestion({ const sortedItems = useMemo(() => { return localValue .map((id) => question.choices.find((c) => c.id === id)) - .filter((item): item is TSurveyQuestionChoice => item !== undefined); + .filter((item): item is TSurveyElementChoice => item !== undefined); }, [localValue, question.choices]); const unsortedItems = useMemo(() => { @@ -66,7 +65,7 @@ export function RankingQuestion({ }, [question.choices, question.shuffleOption, localValue, sortedItems, shuffledChoicesIds]); const handleItemClick = useCallback( - (item: TSurveyQuestionChoice) => { + (item: TSurveyElementChoice) => { const isAlreadySorted = localValue.includes(item.id); const newLocalValue = isAlreadySorted ? localValue.filter((id) => id !== item.id) @@ -77,7 +76,7 @@ export function RankingQuestion({ // Immediately update parent state with the new ranking const sortedLabels = newLocalValue .map((id) => question.choices.find((c) => c.id === id)) - .filter((item): item is TSurveyQuestionChoice => item !== undefined) + .filter((item): item is TSurveyElementChoice => item !== undefined) .map((item) => getLocalizedValue(item.label, languageCode)); onChange({ [question.id]: sortedLabels }); @@ -101,7 +100,7 @@ export function RankingQuestion({ // Immediately update parent state with the new ranking const sortedLabels = newLocalValue .map((id) => question.choices.find((c) => c.id === id)) - .filter((item): item is TSurveyQuestionChoice => item !== undefined) + .filter((item): item is TSurveyElementChoice => item !== undefined) .map((item) => getLocalizedValue(item.label, languageCode)); onChange({ [question.id]: sortedLabels }); diff --git a/packages/surveys/src/lib/logic.ts b/packages/surveys/src/lib/logic.ts index d49a52f6a8..492f22302f 100644 --- a/packages/surveys/src/lib/logic.ts +++ b/packages/surveys/src/lib/logic.ts @@ -5,7 +5,7 @@ import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/survey import { TConditionGroup, TSingleCondition } from "@formbricks/types/surveys/logic"; import { TSurveyVariable } from "@formbricks/types/surveys/types"; import { getLocalizedValue } from "@/lib/i18n"; -import { getElementsFromSurvey } from "./utils"; +import { getElementsFromSurveyBlocks } from "./utils"; const getVariableValue = ( variables: TSurveyVariable[], @@ -89,7 +89,7 @@ const getLeftOperandValue = ( ) => { switch (leftOperand.type) { case "question": - const questions = getElementsFromSurvey(localSurvey); + const questions = getElementsFromSurveyBlocks(localSurvey.blocks); const currentQuestion = questions.find((q) => q.id === leftOperand.value); if (!currentQuestion) return undefined; @@ -223,7 +223,7 @@ const evaluateSingleCondition = ( let leftField: TSurveyElement | TSurveyVariable | string; - const questions = getElementsFromSurvey(localSurvey); + const questions = getElementsFromSurveyBlocks(localSurvey.blocks); if (condition.leftOperand?.type === "question") { leftField = questions.find((q) => q.id === condition.leftOperand?.value) ?? ""; } else if (condition.leftOperand?.type === "variable") { diff --git a/packages/surveys/src/lib/utils.test.ts b/packages/surveys/src/lib/utils.test.ts index f935fdb965..d2d7de769b 100644 --- a/packages/surveys/src/lib/utils.test.ts +++ b/packages/surveys/src/lib/utils.test.ts @@ -2,11 +2,11 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import type { TJsEnvironmentStateSurvey } from "../../../types/js"; import { type TAllowedFileExtension, mimeTypes } from "../../../types/storage"; import { TSurveyElementTypeEnum } from "../../../types/surveys/elements"; -import type { TSurveyLanguage, TSurveyQuestionChoice } from "../../../types/surveys/types"; +import type { TSurveyLanguage } from "../../../types/surveys/types"; import { findBlockByElementId, getDefaultLanguageCode, - getElementsFromSurvey, + getElementsFromSurveyBlocks, getMimeType, getShuffledChoicesIds, getShuffledRowIndices, @@ -140,12 +140,12 @@ describe("getShuffledChoicesIds", () => { mockGetRandomValues.mockReset(); }); - const choicesBase: TSurveyQuestionChoice[] = [ + const choicesBase = [ { id: "c1", label: { en: "Choice 1" } }, { id: "c2", label: { en: "Choice 2" } }, { id: "c3", label: { en: "Choice 3" } }, ]; - const choicesWithOther: TSurveyQuestionChoice[] = [...choicesBase, { id: "other", label: { en: "Other" } }]; + const choicesWithOther = [...choicesBase, { id: "other", label: { en: "Other" } }]; test('should return unshuffled for "none"', () => { expect(getShuffledChoicesIds(choicesBase, "none")).toEqual(["c1", "c2", "c3"]); @@ -225,7 +225,7 @@ describe("getQuestionsFromSurvey", () => { ], }; - const questions = getElementsFromSurvey(survey); + const questions = getElementsFromSurveyBlocks(survey.blocks); expect(questions).toHaveLength(3); expect(questions[0].id).toBe("q1"); expect(questions[1].id).toBe("q2"); @@ -238,7 +238,7 @@ describe("getQuestionsFromSurvey", () => { blocks: [], } as TJsEnvironmentStateSurvey; - expect(getElementsFromSurvey(survey)).toEqual([]); + expect(getElementsFromSurveyBlocks(survey.blocks)).toEqual([]); }); test("should handle blocks with no elements", () => { @@ -263,7 +263,7 @@ describe("getQuestionsFromSurvey", () => { ], }; - const questions = getElementsFromSurvey(survey); + const questions = getElementsFromSurveyBlocks(survey.blocks); expect(questions).toHaveLength(1); expect(questions[0].id).toBe("q1"); }); @@ -313,17 +313,17 @@ describe("findBlockByElementId", () => { }; test("should find block containing the element", () => { - const block = findBlockByElementId(survey, "q1"); + const block = findBlockByElementId(survey.blocks, "q1"); expect(block).toBeDefined(); expect(block?.id).toBe("block1"); - const block2 = findBlockByElementId(survey, "q3"); + const block2 = findBlockByElementId(survey.blocks, "q3"); expect(block2).toBeDefined(); expect(block2?.id).toBe("block2"); }); test("should return undefined for non-existent element", () => { - const block = findBlockByElementId(survey, "nonexistent"); + const block = findBlockByElementId(survey.blocks, "nonexistent"); expect(block).toBeUndefined(); }); }); diff --git a/packages/surveys/src/lib/utils.ts b/packages/surveys/src/lib/utils.ts index 31c893c631..05530d47db 100644 --- a/packages/surveys/src/lib/utils.ts +++ b/packages/surveys/src/lib/utils.ts @@ -2,9 +2,9 @@ import { type Result, err, ok, wrapThrowsAsync } from "@formbricks/types/error-h import { type ApiErrorResponse } from "@formbricks/types/errors"; import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js"; import { TAllowedFileExtension, mimeTypes } from "@formbricks/types/storage"; -import { TSurveyBlockLogic, TSurveyBlockLogicAction } from "@formbricks/types/surveys/blocks"; -import { type TSurveyElement } from "@formbricks/types/surveys/elements"; -import { type TShuffleOption, type TSurveyQuestionChoice } from "@formbricks/types/surveys/types"; +import { TSurveyBlock, TSurveyBlockLogic, TSurveyBlockLogicAction } from "@formbricks/types/surveys/blocks"; +import { type TSurveyElement, TSurveyElementChoice } from "@formbricks/types/surveys/elements"; +import { type TShuffleOption } from "@formbricks/types/surveys/types"; import { ApiResponse, ApiSuccessResponse } from "@/types/api"; export const cn = (...classes: string[]) => { @@ -41,7 +41,7 @@ export const getShuffledRowIndices = (n: number, shuffleOption: TShuffleOption): }; export const getShuffledChoicesIds = ( - choices: TSurveyQuestionChoice[], + choices: TSurveyElementChoice[], shuffleOption: TShuffleOption ): string[] => { const otherOption = choices.find((choice) => { @@ -79,10 +79,10 @@ export const calculateElementIdx = ( currentQustionIdx: number, totalCards: number ): number => { - const questions = getElementsFromSurvey(survey); + const questions = getElementsFromSurveyBlocks(survey.blocks); const currentQuestion = questions[currentQustionIdx]; const middleIdx = Math.floor(totalCards / 2); - const possibleNextBlockIds = getPossibleNextBlocks(survey, currentQuestion); + const possibleNextBlockIds = getPossibleNextBlocks(survey.blocks, currentQuestion); const endingCardIds = survey.endings.map((ending) => ending.id); // Convert block IDs to element IDs (get first element of each block) @@ -106,9 +106,9 @@ export const calculateElementIdx = ( return elementIdx; }; -const getPossibleNextBlocks = (survey: TJsEnvironmentStateSurvey, element: TSurveyElement): string[] => { +const getPossibleNextBlocks = (blocks: TSurveyBlock[], element: TSurveyElement): string[] => { // In the blocks model, logic is stored at the block level - const parentBlock = findBlockByElementId(survey, element.id); + const parentBlock = findBlockByElementId(blocks, element.id); if (!parentBlock?.logic) return []; const possibleBlockIds: string[] = []; @@ -197,7 +197,7 @@ export const checkIfSurveyIsRTL = (survey: TJsEnvironmentStateSurvey, languageCo } } - const questions = getElementsFromSurvey(survey); + const questions = getElementsFromSurveyBlocks(survey.blocks); for (const question of questions) { const questionHeadline = question.headline[languageCode]; @@ -212,11 +212,11 @@ export const checkIfSurveyIsRTL = (survey: TJsEnvironmentStateSurvey, languageCo /** * Derives a flat array of elements from the survey's blocks structure. - * @param survey The survey object with blocks + * @param blocks The blocks array * @returns An array of TSurveyElement (pure elements without block-level properties) */ -export const getElementsFromSurvey = (survey: TJsEnvironmentStateSurvey): TSurveyElement[] => - survey.blocks.flatMap((block) => block.elements); +export const getElementsFromSurveyBlocks = (blocks: TSurveyBlock[]): TSurveyElement[] => + blocks.flatMap((block) => block.elements); /** * Finds the parent block that contains the specified element ID. @@ -225,8 +225,8 @@ export const getElementsFromSurvey = (survey: TJsEnvironmentStateSurvey): TSurve * @param elementId The ID of the element to find * @returns The parent block or undefined if not found */ -export const findBlockByElementId = (survey: TJsEnvironmentStateSurvey, elementId: string) => - survey.blocks.find((b) => b.elements.some((e) => e.id === elementId)); +export const findBlockByElementId = (blocks: TSurveyBlock[], elementId: string) => + blocks.find((block) => block.elements.some((e) => e.id === elementId)); /** * Converts a block ID to the first element ID in that block. @@ -242,39 +242,3 @@ export const getFirstElementIdInBlock = ( const block = survey.blocks.find((b) => b.id === blockId); return block?.elements[0]?.id; }; - -/** - * Gets a block by its ID. - * @param survey The survey object - * @param blockId The block ID to find - * @returns The block or undefined if not found - */ -export const getBlockById = (survey: TJsEnvironmentStateSurvey, blockId: string) => { - return survey.blocks.find((b) => b.id === blockId); -}; - -/** - * Gets the next block ID after the current block. - * @param survey The survey object - * @param currentBlockId The current block ID - * @returns The next block ID or undefined if current block is last - */ -export const getNextBlockId = ( - survey: TJsEnvironmentStateSurvey, - currentBlockId: string -): string | undefined => { - const currentIndex = survey.blocks.findIndex((b) => b.id === currentBlockId); - if (currentIndex === -1) return undefined; - return survey.blocks[currentIndex + 1]?.id; -}; - -/** - * Gets all element IDs in a block. - * @param survey The survey object - * @param blockId The block ID - * @returns Array of element IDs in the block - */ -export const getElementIdsInBlock = (survey: TJsEnvironmentStateSurvey, blockId: string): string[] => { - const block = getBlockById(survey, blockId); - return block?.elements.map((e) => e.id) ?? []; -}; diff --git a/packages/types/formbricks-surveys.ts b/packages/types/formbricks-surveys.ts index c9c429f615..e091c6aa66 100644 --- a/packages/types/formbricks-surveys.ts +++ b/packages/types/formbricks-surveys.ts @@ -10,7 +10,6 @@ export interface SurveyBaseProps { isBrandingEnabled: boolean; getSetIsError?: (getSetError: (value: boolean) => void) => void; getSetIsResponseSendingFinished?: (getSetIsResponseSendingFinished: (value: boolean) => void) => void; - getSetQuestionId?: (getSetQuestionId: (value: string) => void) => void; getSetBlockId?: (getSetBlockId: (value: string) => void) => void; getSetResponseData?: (getSetResponseData: (value: TResponseData) => void) => void; onDisplay?: () => Promise; diff --git a/packages/types/surveys/elements.ts b/packages/types/surveys/elements.ts index 8990deed4f..8ce1f49fee 100644 --- a/packages/types/surveys/elements.ts +++ b/packages/types/surveys/elements.ts @@ -126,6 +126,8 @@ export const ZSurveyElementChoice = z.object({ label: ZI18nString, }); +export type TSurveyElementChoice = z.infer; + export const ZShuffleOption = z.enum(["none", "all", "exceptLast"]); export type TShuffleOption = z.infer; From d4f7f0f35d976000e9cf4d55b2697cd3d4c77402 Mon Sep 17 00:00:00 2001 From: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com> Date: Thu, 20 Nov 2025 21:02:25 +0530 Subject: [PATCH 02/20] feat: blocks UI Part 1 (#6816) --- .../(analysis)/summary/lib/surveySummary.ts | 4 +- apps/web/i18n.lock | 12 +- apps/web/locales/de-DE.json | 12 +- apps/web/locales/en-US.json | 12 +- apps/web/locales/fr-FR.json | 12 +- apps/web/locales/ja-JP.json | 12 +- apps/web/locales/pt-BR.json | 12 +- apps/web/locales/pt-PT.json | 12 +- apps/web/locales/ro-RO.json | 12 +- apps/web/locales/zh-Hans-CN.json | 12 +- apps/web/locales/zh-Hant-TW.json | 12 +- .../components/question-form-input/index.tsx | 18 +- .../editor/components/add-question-button.tsx | 5 +- .../add-question-to-block-button.tsx | 96 ++ .../components/address-question-form.tsx | 7 +- .../editor/components/advanced-settings.tsx | 5 +- .../survey/editor/components/block-card.tsx | 828 +++++++++++++ .../survey/editor/components/block-menu.tsx | 90 ++ .../editor/components/blocks-droppable.tsx | 125 ++ .../editor/components/cal-question-form.tsx | 7 +- .../components/consent-question-form.tsx | 7 +- .../components/contact-info-question-form.tsx | 7 +- .../editor/components/cta-question-form.tsx | 90 +- .../editor/components/date-question-form.tsx | 7 +- .../editor/components/editor-card-menu.tsx | 86 +- .../components/file-upload-question-form.tsx | 7 +- .../components/matrix-question-form.tsx | 7 +- .../components/matrix-sortable-item.tsx | 7 +- .../multiple-choice-question-form.tsx | 22 +- .../editor/components/nps-question-form.tsx | 12 +- .../editor/components/open-question-form.tsx | 15 +- .../components/picture-selection-form.tsx | 7 +- .../editor/components/question-card.tsx | 698 ----------- .../components/question-option-choice.tsx | 17 +- .../editor/components/questions-droppable.tsx | 108 -- .../editor/components/questions-view.tsx | 231 +++- .../components/ranking-question-form.tsx | 7 +- .../components/rating-question-form.tsx | 18 +- .../editor/components/survey-editor.tsx | 2 +- .../modules/survey/editor/lib/blocks.test.ts | 1099 +++++++++++------ apps/web/modules/survey/editor/lib/blocks.ts | 51 + .../survey/editor/lib/validation.test.ts | 160 +-- .../modules/survey/editor/lib/validation.ts | 154 ++- apps/web/modules/survey/lib/questions.tsx | 82 +- .../survey/link/components/link-survey.tsx | 12 +- .../ui/components/preview-survey/index.tsx | 55 +- .../question-toggle-table/index.tsx | 5 +- .../shuffle-option-select/index.tsx | 12 +- packages/js-core/src/types/survey.ts | 1 - .../components/general/block-conditional.tsx | 266 ++++ .../general/element-conditional.tsx | 340 +++++ .../src/components/general/progress-bar.tsx | 35 +- .../general/question-conditional.tsx | 399 ------ .../surveys/src/components/general/survey.tsx | 206 +-- .../src/components/general/welcome-card.tsx | 4 +- .../src/components/icons/link-icon.tsx | 23 + .../components/questions/address-question.tsx | 153 +-- .../src/components/questions/cal-question.tsx | 117 +- .../components/questions/consent-question.tsx | 146 +-- .../questions/contact-info-question.tsx | 162 +-- .../src/components/questions/cta-question.tsx | 119 +- .../components/questions/date-question.tsx | 293 ++--- .../questions/file-upload-question.tsx | 121 +- .../components/questions/matrix-question.tsx | 187 ++- .../multiple-choice-multi-question.tsx | 404 +++--- .../multiple-choice-single-question.tsx | 381 +++--- .../src/components/questions/nps-question.tsx | 233 ++-- .../questions/open-text-question.tsx | 200 ++- .../questions/picture-selection-question.tsx | 259 ++-- .../components/questions/ranking-question.tsx | 345 +++--- .../components/questions/rating-question.tsx | 353 +++--- .../wrappers/stacked-cards-container.tsx | 147 ++- packages/surveys/src/lib/logic.ts | 6 +- packages/surveys/src/lib/utils.test.ts | 20 +- packages/surveys/src/lib/utils.ts | 28 +- packages/types/formbricks-surveys.ts | 2 +- packages/types/surveys/elements.ts | 7 +- packages/types/surveys/types.ts | 30 +- 78 files changed, 4756 insertions(+), 4521 deletions(-) create mode 100644 apps/web/modules/survey/editor/components/add-question-to-block-button.tsx create mode 100644 apps/web/modules/survey/editor/components/block-card.tsx create mode 100644 apps/web/modules/survey/editor/components/block-menu.tsx create mode 100644 apps/web/modules/survey/editor/components/blocks-droppable.tsx delete mode 100644 apps/web/modules/survey/editor/components/question-card.tsx delete mode 100644 apps/web/modules/survey/editor/components/questions-droppable.tsx create mode 100644 packages/surveys/src/components/general/block-conditional.tsx create mode 100644 packages/surveys/src/components/general/element-conditional.tsx delete mode 100644 packages/surveys/src/components/general/question-conditional.tsx create mode 100644 packages/surveys/src/components/icons/link-icon.tsx diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts index 36c7a386f3..12cb17c302 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts @@ -18,6 +18,7 @@ import { TSurveyAddressElement, TSurveyContactInfoElement, TSurveyElement, + TSurveyElementChoice, TSurveyElementTypeEnum, } from "@formbricks/types/surveys/elements"; import { @@ -33,7 +34,6 @@ import { TSurveyElementSummaryRanking, TSurveyElementSummaryRating, TSurveyLanguage, - TSurveyQuestionChoice, TSurveySummary, } from "@formbricks/types/surveys/types"; import { getTextContent } from "@formbricks/types/surveys/validation"; @@ -323,7 +323,7 @@ const checkForI18n = ( // Return the localized value of the choice fo multiSelect single question if (question && "choices" in question) { const choice = question.choices?.find( - (choice: TSurveyQuestionChoice) => choice.label?.[languageCode] === responseData[id] + (choice: TSurveyElementChoice) => choice.label?.[languageCode] === responseData[id] ); return choice && "label" in choice ? getLocalizedValue(choice.label, "default") || responseData[id] diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index 76aeabcfe4..1ff843fbed 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -1103,9 +1103,9 @@ checksums: environments/surveys/edit/add: 5196f5cd4ba3a6ac8edef91345e17f66 environments/surveys/edit/add_a_delay_or_auto_close_the_survey: b5fa358bf3ff324014060eb0baf6dd2f environments/surveys/edit/add_a_four_digit_pin: 953cb3673d2135923e3b4474d33ffb2c - environments/surveys/edit/add_a_new_question_to_your_survey: 65f3a4f0d5132eab7aeaed1ad28df56c environments/surveys/edit/add_a_variable_to_calculate: c202b50c12fc6f71f06eaf6f1b61e961 environments/surveys/edit/add_action_below: 46cdbf9a77391aa89593908e508f7af0 + environments/surveys/edit/add_block: ae8fbf8fdb5c6be7e4951a6cdd486473 environments/surveys/edit/add_choice_below: abf0416f7a78df61869de63d9766683c environments/surveys/edit/add_color_coding: db738f7be21e08c5dc878c09fdf95e44 environments/surveys/edit/add_color_coding_description: da15c619aa00084ad18f30766906527f @@ -1126,7 +1126,6 @@ checksums: environments/surveys/edit/add_other: de75bd3d40f3b5effdbe1c8d536f936b environments/surveys/edit/add_photo_or_video: 7fd213e807ad060e415d1d4195397473 environments/surveys/edit/add_pin: 1bc282dd7eaea51301655d3e8dd3a9fb - environments/surveys/edit/add_question: 10336b52895385f7390540ad5bb4e208 environments/surveys/edit/add_question_below: 58e64eb2e013f1175ea0dcf79149109f environments/surveys/edit/add_row: a613cef4caf1f0e05697c8de5164e2a3 environments/surveys/edit/add_variable: 23f97e23aba763cc58934df4fa13ffc1 @@ -1154,12 +1153,12 @@ checksums: environments/surveys/edit/automatically_mark_the_survey_as_complete_after: c6ede2a5515a4ca72b36aec2583f43aa environments/surveys/edit/back_button_label: 25af945e77336724b5276de291cc92d9 environments/surveys/edit/background_styling: 4e1e6fd2ec767bbff8767f6c0f68a731 + environments/surveys/edit/block_deleted: c682259eb138ad84f8b4441abfd9b572 + environments/surveys/edit/block_duplicated: dc9e9fab2b1cd91f6c265324b34c6376 environments/surveys/edit/bold: 4d7306bc355ed2befd6a9237c5452ee6 environments/surveys/edit/brand_color: 84ddb5736deb9f5c081ffe4962a6c63e environments/surveys/edit/brightness: 45425b6db1872225bfff71cf619d0e64 environments/surveys/edit/button_label: db3cd7c74f393187bd780c5c3d8b9b4f - environments/surveys/edit/button_to_continue_in_survey: 931d87aaf360ab7521f9dd75795a42d0 - environments/surveys/edit/button_to_link_to_external_url: 7c7cf54e8dc86240b86964133e802888 environments/surveys/edit/button_url: 6f39f649a165a11873c11ea6403dba90 environments/surveys/edit/cal_username: a4a9c739af909d975beb1bc4998feae9 environments/surveys/edit/calculate: c5fcf8d3a38706ae2071b6f78339ec68 @@ -1198,6 +1197,7 @@ checksums: environments/surveys/edit/character_limit_toggle_title: fdc45bcc6335e5116aec895fecda0d87 environments/surveys/edit/checkbox_label: 12a07d6bdf38e283a2e95892ec49b7f8 environments/surveys/edit/choose_the_actions_which_trigger_the_survey: 773b311a148a112243f3b139506b9987 + environments/surveys/edit/choose_the_first_question_on_your_block: bdece06ca04f89d0c445ba1554dd5b80 environments/surveys/edit/choose_where_to_run_the_survey: ad87bcae97c445f1fd9ac110ea24f117 environments/surveys/edit/city: 1831f32e1babbb29af27fac3053504a2 environments/surveys/edit/close_survey_on_response_limit: 256d0bccdbcbb3d20e39aabc5b376e5e @@ -1226,6 +1226,7 @@ checksums: environments/surveys/edit/date_format: e95dfc41ac944874868487457ddc057a environments/surveys/edit/days_before_showing_this_survey_again: 8b4623eab862615fa60064400008eb23 environments/surveys/edit/decide_how_often_people_can_answer_this_survey: 58427b0f0a7a258c24fa2acd9913e95e + environments/surveys/edit/delete_block: c00617cb0724557e486304276063807a environments/surveys/edit/delete_choice: fd750208d414b9ad8c980c161a0199e1 environments/surveys/edit/disable_the_visibility_of_survey_progress: 2af631010114307ac2a91612559c9618 environments/surveys/edit/display_an_estimate_of_completion_time_for_survey: 03f0a816569399c1c61d08dbc913de06 @@ -1237,6 +1238,7 @@ checksums: environments/surveys/edit/does_not_include_all_of: c18c1a71e6d96c681a3e95c7bd6c9482 environments/surveys/edit/does_not_include_one_of: 91090d2e0667faf654f6a81d9857440f environments/surveys/edit/does_not_start_with: 9395869b54cdfb353a51a7e0864f4fd7 + environments/surveys/edit/duplicate_block: d4ea4afb5fc5b18a81cbe0302fa05997 environments/surveys/edit/edit_link: 40ba9e15beac77a46c5baf30be84ac54 environments/surveys/edit/edit_recall: 38a4a7378d02453e35d06f2532eef318 environments/surveys/edit/edit_translations: 2b21bea4b53e88342559272701e9fbf3 @@ -1491,7 +1493,6 @@ checksums: environments/surveys/edit/show_to_x_percentage_of_targeted_users: b745169011fa7e8ca475baa5500c5197 environments/surveys/edit/simple: 65575bd903091299bc4a94b7517a6288 environments/surveys/edit/six_points: c6c09b3f07171dc388cb5a610ea79af7 - environments/surveys/edit/skip_button_label: bfc8993b0f13e6f4fc9ef0c570b808e3 environments/surveys/edit/smiley: e68e3b28fc3c04255e236c6a0feb662b environments/surveys/edit/spam_protection_note: 94059310d07c30f6704e216297036d05 environments/surveys/edit/spam_protection_threshold_description: ed8b8c9c583077a88bf5dd3ec8b59e60 @@ -2667,7 +2668,6 @@ checksums: templates/site_abandonment_survey_question_7_label: c0d4407cabb5811192c17cbbb8c1a71e templates/site_abandonment_survey_question_8_headline: 9e82d6f51788351c7e2c8f73be66d005 templates/site_abandonment_survey_question_9_headline: ef1289130df46b80d43119380095b579 - templates/skip: b7f28dfa2f58b80b149bb82b392d0291 templates/smileys_survey_name: 6ef64e8182e7820efa53a2d1c81eb912 templates/smileys_survey_question_1_headline: 6b15d118037b729138c2214cfef49a68 templates/smileys_survey_question_1_lower_label: ff4681be0a94185111459994fe58478c diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index ef73efa5e3..6fb95c3cb8 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -1188,9 +1188,9 @@ "add": "+ hinzufügen", "add_a_delay_or_auto_close_the_survey": "Füge eine Verzögerung hinzu oder schließe die Umfrage automatisch.", "add_a_four_digit_pin": "Füge eine vierstellige PIN hinzu", - "add_a_new_question_to_your_survey": "Neue Frage hinzufügen", "add_a_variable_to_calculate": "Variable hinzufügen", "add_action_below": "Aktion unten hinzufügen", + "add_block": "Block hinzufügen", "add_choice_below": "Auswahl unten hinzufügen", "add_color_coding": "Farbkodierung hinzufügen", "add_color_coding_description": "Füge rote, orange und grüne Farbcodes zu den Optionen hinzu.", @@ -1211,7 +1211,6 @@ "add_other": "Anderes hinzufügen", "add_photo_or_video": "Foto oder Video hinzufügen", "add_pin": "PIN hinzufügen", - "add_question": "Frage hinzufügen", "add_question_below": "Frage unten hinzufügen", "add_row": "Zeile hinzufügen", "add_variable": "Variable hinzufügen", @@ -1239,12 +1238,12 @@ "automatically_mark_the_survey_as_complete_after": "Umfrage automatisch als abgeschlossen markieren nach", "back_button_label": "Zurück\"- Button ", "background_styling": "Hintergründe", + "block_deleted": "Block gelöscht.", + "block_duplicated": "Block dupliziert.", "bold": "Fett", "brand_color": "Markenfarbe", "brightness": "Helligkeit", "button_label": "Beschriftung", - "button_to_continue_in_survey": "Fahre in der Umfrage fort", - "button_to_link_to_external_url": "Verlinke auf externe URL", "button_url": "URL", "cal_username": "Cal.com Benutzername oder Benutzername/Ereignis", "calculate": "Berechnen", @@ -1283,6 +1282,7 @@ "character_limit_toggle_title": "Fügen Sie Zeichenbeschränkungen hinzu", "checkbox_label": "Checkbox-Beschriftung", "choose_the_actions_which_trigger_the_survey": "Aktionen auswählen, die die Umfrage auslösen.", + "choose_the_first_question_on_your_block": "Wählen sie die erste frage in ihrem block", "choose_where_to_run_the_survey": "Wähle, wo die Umfrage durchgeführt werden soll.", "city": "Stadt", "close_survey_on_response_limit": "Umfrage bei Erreichen des Antwortlimits schließen", @@ -1311,6 +1311,7 @@ "date_format": "Datumsformat", "days_before_showing_this_survey_again": "Tage, bevor diese Umfrage erneut angezeigt wird.", "decide_how_often_people_can_answer_this_survey": "Entscheide, wie oft Leute diese Umfrage beantworten können.", + "delete_block": "Block löschen", "delete_choice": "Auswahl löschen", "disable_the_visibility_of_survey_progress": "Deaktiviere die Sichtbarkeit des Umfragefortschritts.", "display_an_estimate_of_completion_time_for_survey": "Zeige eine Schätzung der Fertigstellungszeit für die Umfrage an", @@ -1322,6 +1323,7 @@ "does_not_include_all_of": "Enthält nicht alle von", "does_not_include_one_of": "Enthält nicht eines von", "does_not_start_with": "Fängt nicht an mit", + "duplicate_block": "Block duplizieren", "edit_link": "Bearbeitungslink", "edit_recall": "Erinnerung bearbeiten", "edit_translations": "{lang} -Übersetzungen bearbeiten", @@ -1578,7 +1580,6 @@ "show_to_x_percentage_of_targeted_users": "Zeige {percentage}% der Zielbenutzer", "simple": "Einfach", "six_points": "6 Punkte", - "skip_button_label": "Überspringen-Button-Beschriftung", "smiley": "Smiley", "spam_protection_note": "Spamschutz funktioniert nicht für Umfragen, die mit den iOS-, React Native- und Android-SDKs angezeigt werden. Es wird die Umfrage unterbrechen.", "spam_protection_threshold_description": "Wert zwischen 0 und 1 festlegen, Antworten unter diesem Wert werden abgelehnt.", @@ -2814,7 +2815,6 @@ "site_abandonment_survey_question_7_label": "Ja, bitte melde dich.", "site_abandonment_survey_question_8_headline": "Bitte teile deine E-Mail-Adresse:", "site_abandonment_survey_question_9_headline": "Weitere Kommentare oder Vorschläge?", - "skip": "Überspringen", "smileys_survey_name": "Smileys-Umfrage", "smileys_survey_question_1_headline": "Wie gefällt dir $[projectName]?", "smileys_survey_question_1_lower_label": "Nicht gut", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index 6454f6e25b..a30a356664 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -1188,9 +1188,9 @@ "add": "Add +", "add_a_delay_or_auto_close_the_survey": "Add a delay or auto-close the survey", "add_a_four_digit_pin": "Add a four digit PIN", - "add_a_new_question_to_your_survey": "Add a new question to your survey", "add_a_variable_to_calculate": "Add a variable to calculate", "add_action_below": "Add action below", + "add_block": "Add Block", "add_choice_below": "Add choice below", "add_color_coding": "Add color coding", "add_color_coding_description": "Add red, orange and green color codes to the options.", @@ -1211,7 +1211,6 @@ "add_other": "Add \"Other\"", "add_photo_or_video": "Add photo or video", "add_pin": "Add PIN", - "add_question": "Add question", "add_question_below": "Add question below", "add_row": "Add row", "add_variable": "Add variable", @@ -1239,12 +1238,12 @@ "automatically_mark_the_survey_as_complete_after": "Automatically mark the survey as complete after", "back_button_label": "\"Back\" Button Label", "background_styling": "Background Styling", + "block_deleted": "Block deleted.", + "block_duplicated": "Block duplicated.", "bold": "Bold", "brand_color": "Brand color", "brightness": "Brightness", "button_label": "Button Label", - "button_to_continue_in_survey": "Button to continue in survey", - "button_to_link_to_external_url": "Button to link to external URL", "button_url": "Button URL", "cal_username": "Cal.com username or username/event", "calculate": "Calculate", @@ -1283,6 +1282,7 @@ "character_limit_toggle_title": "Add character limits", "checkbox_label": "Checkbox Label", "choose_the_actions_which_trigger_the_survey": "Choose the actions which trigger the survey.", + "choose_the_first_question_on_your_block": "Choose the first question on your Block", "choose_where_to_run_the_survey": "Choose where to run the survey.", "city": "City", "close_survey_on_response_limit": "Close survey on response limit", @@ -1311,6 +1311,7 @@ "date_format": "Date format", "days_before_showing_this_survey_again": "days before showing this survey again.", "decide_how_often_people_can_answer_this_survey": "Decide how often people can answer this survey.", + "delete_block": "Delete block", "delete_choice": "Delete choice", "disable_the_visibility_of_survey_progress": "Disable the visibility of survey progress.", "display_an_estimate_of_completion_time_for_survey": "Display an estimate of completion time for survey", @@ -1322,6 +1323,7 @@ "does_not_include_all_of": "Does not include all of", "does_not_include_one_of": "Does not include one of", "does_not_start_with": "Does not start with", + "duplicate_block": "Duplicate block", "edit_link": "Edit link", "edit_recall": "Edit Recall", "edit_translations": "Edit {lang} translations", @@ -1578,7 +1580,6 @@ "show_to_x_percentage_of_targeted_users": "Show to {percentage}% of targeted users", "simple": "Simple", "six_points": "6 points", - "skip_button_label": "Skip Button Label", "smiley": "Smiley", "spam_protection_note": "Spam protection does not work for surveys displayed with the iOS, React Native, and Android SDKs. It will break the survey.", "spam_protection_threshold_description": "Set value between 0 and 1, responses below this value will be rejected.", @@ -2814,7 +2815,6 @@ "site_abandonment_survey_question_7_label": "Yes, please reach out.", "site_abandonment_survey_question_8_headline": "Please share your email address:", "site_abandonment_survey_question_9_headline": "Any additional comments or suggestions?", - "skip": "Skip", "smileys_survey_name": "Smileys Survey", "smileys_survey_question_1_headline": "How do you like $[projectName]?", "smileys_survey_question_1_lower_label": "Not good", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index cbde982d45..a5290646db 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -1188,9 +1188,9 @@ "add": "Ajouter +", "add_a_delay_or_auto_close_the_survey": "Ajouter un délai ou fermer automatiquement l'enquête", "add_a_four_digit_pin": "Ajoutez un code PIN à quatre chiffres.", - "add_a_new_question_to_your_survey": "Ajouter une nouvelle question à votre enquête", "add_a_variable_to_calculate": "Ajouter une variable à calculer", "add_action_below": "Ajouter une action ci-dessous", + "add_block": "Ajouter un bloc", "add_choice_below": "Ajouter une option ci-dessous", "add_color_coding": "Ajouter un code couleur", "add_color_coding_description": "Ajoutez des codes de couleur rouge, orange et vert aux options.", @@ -1211,7 +1211,6 @@ "add_other": "Ajouter \"Autre", "add_photo_or_video": "Ajouter une photo ou une vidéo", "add_pin": "Ajouter un code PIN", - "add_question": "Ajouter une question", "add_question_below": "Ajouter une question ci-dessous", "add_row": "Ajouter une ligne", "add_variable": "Ajouter une variable", @@ -1239,12 +1238,12 @@ "automatically_mark_the_survey_as_complete_after": "Marquer automatiquement l'enquête comme terminée après", "back_button_label": "Label du bouton \"Retour''", "background_styling": "Style de fond", + "block_deleted": "Bloc supprimé.", + "block_duplicated": "Bloc dupliqué.", "bold": "Gras", "brand_color": "Couleur de marque", "brightness": "Luminosité", "button_label": "Label du bouton", - "button_to_continue_in_survey": "Bouton pour continuer dans l'enquête", - "button_to_link_to_external_url": "Bouton pour lier à une URL externe", "button_url": "URL du bouton", "cal_username": "Nom d'utilisateur Cal.com ou nom d'utilisateur/événement", "calculate": "Calculer", @@ -1283,6 +1282,7 @@ "character_limit_toggle_title": "Ajouter des limites de caractères", "checkbox_label": "Étiquette de case à cocher", "choose_the_actions_which_trigger_the_survey": "Choisissez les actions qui déclenchent l'enquête.", + "choose_the_first_question_on_your_block": "Choisissez la première question de votre bloc", "choose_where_to_run_the_survey": "Choisissez où réaliser l'enquête.", "city": "Ville", "close_survey_on_response_limit": "Fermer l'enquête sur la limite de réponse", @@ -1311,6 +1311,7 @@ "date_format": "Format de date", "days_before_showing_this_survey_again": "jours avant de montrer à nouveau cette enquête.", "decide_how_often_people_can_answer_this_survey": "Décidez à quelle fréquence les gens peuvent répondre à cette enquête.", + "delete_block": "Supprimer le bloc", "delete_choice": "Supprimer l'option", "disable_the_visibility_of_survey_progress": "Désactiver la visibilité de la progression du sondage.", "display_an_estimate_of_completion_time_for_survey": "Afficher une estimation du temps de complétion pour l'enquête.", @@ -1322,6 +1323,7 @@ "does_not_include_all_of": "n'inclut pas tout", "does_not_include_one_of": "n'inclut pas un de", "does_not_start_with": "Ne commence pas par", + "duplicate_block": "Dupliquer le bloc", "edit_link": "Modifier le lien", "edit_recall": "Modifier le rappel", "edit_translations": "Modifier les traductions {lang}", @@ -1578,7 +1580,6 @@ "show_to_x_percentage_of_targeted_users": "Afficher à {percentage}% des utilisateurs ciblés", "simple": "Simple", "six_points": "6 points", - "skip_button_label": "Étiquette du bouton Ignorer", "smiley": "Sourire", "spam_protection_note": "La protection contre le spam ne fonctionne pas pour les enquêtes affichées avec les SDK iOS, React Native et Android. Cela cassera l'enquête.", "spam_protection_threshold_description": "Définir une valeur entre 0 et 1, les réponses en dessous de cette valeur seront rejetées.", @@ -2814,7 +2815,6 @@ "site_abandonment_survey_question_7_label": "Oui, veuillez me contacter.", "site_abandonment_survey_question_8_headline": "Veuillez partager votre adresse e-mail :", "site_abandonment_survey_question_9_headline": "Avez-vous des commentaires ou des suggestions supplémentaires ?", - "skip": "Sauter", "smileys_survey_name": "Sondage des Émoticônes", "smileys_survey_question_1_headline": "Que pensez-vous de $[projectName] ?", "smileys_survey_question_1_lower_label": "Pas bon", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 6329de169a..4b54788aea 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -1188,9 +1188,9 @@ "add": "追加 +", "add_a_delay_or_auto_close_the_survey": "遅延を追加するか、フォームを自動的に閉じる", "add_a_four_digit_pin": "4桁のPINを追加", - "add_a_new_question_to_your_survey": "フォームに新しい質問を追加", "add_a_variable_to_calculate": "計算する変数を追加", "add_action_below": "以下にアクションを追加", + "add_block": "ブロックを追加", "add_choice_below": "以下に選択肢を追加", "add_color_coding": "色分けを追加", "add_color_coding_description": "オプションに赤、オレンジ、緑の色コードを追加します。", @@ -1211,7 +1211,6 @@ "add_other": "「その他」を追加", "add_photo_or_video": "写真または動画を追加", "add_pin": "PINを追加", - "add_question": "質問を追加", "add_question_below": "以下に質問を追加", "add_row": "行を追加", "add_variable": "変数を追加", @@ -1239,12 +1238,12 @@ "automatically_mark_the_survey_as_complete_after": "フォームを自動的に完了としてマークする", "back_button_label": "「戻る」ボタンのラベル", "background_styling": "背景のスタイル", + "block_deleted": "ブロックが削除されました。", + "block_duplicated": "ブロックが複製されました。", "bold": "太字", "brand_color": "ブランドカラー", "brightness": "明るさ", "button_label": "ボタンのラベル", - "button_to_continue_in_survey": "フォームを続けるためのボタン", - "button_to_link_to_external_url": "外部URLにリンクするためのボタン", "button_url": "ボタンURL", "cal_username": "Cal.comのユーザー名またはユーザー名/イベント", "calculate": "計算", @@ -1283,6 +1282,7 @@ "character_limit_toggle_title": "文字数制限を追加", "checkbox_label": "チェックボックスのラベル", "choose_the_actions_which_trigger_the_survey": "フォームをトリガーするアクションを選択してください。", + "choose_the_first_question_on_your_block": "ブロックの最初の質問を選択してください", "choose_where_to_run_the_survey": "フォームを実行する場所を選択してください。", "city": "市区町村", "close_survey_on_response_limit": "回答数の上限でフォームを閉じる", @@ -1311,6 +1311,7 @@ "date_format": "日付形式", "days_before_showing_this_survey_again": "日後にこのフォームを再度表示します。", "decide_how_often_people_can_answer_this_survey": "このフォームに人々が何回回答できるかを決定します。", + "delete_block": "ブロックを削除", "delete_choice": "選択肢を削除", "disable_the_visibility_of_survey_progress": "フォームの進捗状況の表示を無効にする。", "display_an_estimate_of_completion_time_for_survey": "フォームの完了時間の目安を表示", @@ -1322,6 +1323,7 @@ "does_not_include_all_of": "のすべてを含まない", "does_not_include_one_of": "のいずれも含まない", "does_not_start_with": "で始まらない", + "duplicate_block": "ブロックを複製", "edit_link": "編集 リンク", "edit_recall": "リコールを編集", "edit_translations": "{lang} 翻訳を編集", @@ -1578,7 +1580,6 @@ "show_to_x_percentage_of_targeted_users": "ターゲットユーザーの {percentage}% に表示", "simple": "シンプル", "six_points": "6点", - "skip_button_label": "スキップボタンのラベル", "smiley": "スマイリー", "spam_protection_note": "スパム対策は、iOS、React Native、およびAndroid SDKで表示されるフォームでは機能しません。フォームが壊れます。", "spam_protection_threshold_description": "値を0から1の間で設定してください。この値より低い回答は拒否されます。", @@ -2814,7 +2815,6 @@ "site_abandonment_survey_question_7_label": "はい、連絡を希望します。", "site_abandonment_survey_question_8_headline": "メールアドレスを教えてください:", "site_abandonment_survey_question_9_headline": "他に何かコメントや提案はありますか?", - "skip": "スキップ", "smileys_survey_name": "スマイリーアンケート", "smileys_survey_question_1_headline": "$[projectName]は好きですか?", "smileys_survey_question_1_lower_label": "良くない", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 53095c02a7..97ae84bce9 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -1188,9 +1188,9 @@ "add": "Adicionar +", "add_a_delay_or_auto_close_the_survey": "Adicione um atraso ou feche a pesquisa automaticamente", "add_a_four_digit_pin": "Adicione um PIN de quatro dígitos", - "add_a_new_question_to_your_survey": "Adicionar uma nova pergunta à sua pesquisa", "add_a_variable_to_calculate": "Adicione uma variável para calcular", "add_action_below": "Adicionar ação abaixo", + "add_block": "Adicionar bloco", "add_choice_below": "Adicionar opção abaixo", "add_color_coding": "Adicionar codificação por cores", "add_color_coding_description": "Adicione os códigos de cores vermelho, laranja e verde às opções.", @@ -1211,7 +1211,6 @@ "add_other": "Adicionar \"Outro", "add_photo_or_video": "Adicionar foto ou video", "add_pin": "Adicionar PIN", - "add_question": "Adicionar pergunta", "add_question_below": "Adicione a pergunta abaixo", "add_row": "Adicionar linha", "add_variable": "Adicionar variável", @@ -1239,12 +1238,12 @@ "automatically_mark_the_survey_as_complete_after": "Marcar automaticamente a pesquisa como concluída após", "back_button_label": "Voltar", "background_styling": "Estilo de Fundo", + "block_deleted": "Bloco excluído.", + "block_duplicated": "Bloco duplicado.", "bold": "Negrito", "brand_color": "Cor da marca", "brightness": "brilho", "button_label": "Rótulo do Botão", - "button_to_continue_in_survey": "Botão para continuar na pesquisa", - "button_to_link_to_external_url": "Botão para link externo", "button_url": "URL do Botão", "cal_username": "Nome de usuário do Cal.com ou nome de usuário/evento", "calculate": "Calcular", @@ -1283,6 +1282,7 @@ "character_limit_toggle_title": "Adicionar limites de caracteres", "checkbox_label": "Rótulo da Caixa de Seleção", "choose_the_actions_which_trigger_the_survey": "Escolha as ações que disparam a pesquisa.", + "choose_the_first_question_on_your_block": "Escolha a primeira pergunta do seu bloco", "choose_where_to_run_the_survey": "Escolha onde realizar a pesquisa.", "city": "cidade", "close_survey_on_response_limit": "Fechar pesquisa ao atingir limite de respostas", @@ -1311,6 +1311,7 @@ "date_format": "Formato de data", "days_before_showing_this_survey_again": "dias antes de mostrar essa pesquisa de novo.", "decide_how_often_people_can_answer_this_survey": "Decida com que frequência as pessoas podem responder a essa pesquisa.", + "delete_block": "Excluir bloco", "delete_choice": "Deletar opção", "disable_the_visibility_of_survey_progress": "Desativar a visibilidade do progresso da pesquisa.", "display_an_estimate_of_completion_time_for_survey": "Mostrar uma estimativa de tempo de conclusão da pesquisa", @@ -1322,6 +1323,7 @@ "does_not_include_all_of": "Não inclui todos de", "does_not_include_one_of": "Não inclui um de", "does_not_start_with": "Não começa com", + "duplicate_block": "Duplicar bloco", "edit_link": "Editar link", "edit_recall": "Editar Lembrete", "edit_translations": "Editar traduções de {lang}", @@ -1578,7 +1580,6 @@ "show_to_x_percentage_of_targeted_users": "Mostrar para {percentage}% dos usuários segmentados", "simple": "Simples", "six_points": "6 pontos", - "skip_button_label": "Botão de Pular", "smiley": "Sorridente", "spam_protection_note": "A proteção contra spam não funciona para pesquisas exibidas com os SDKs iOS, React Native e Android. Isso vai quebrar a pesquisa.", "spam_protection_threshold_description": "Defina um valor entre 0 e 1, respostas abaixo desse valor serão rejeitadas.", @@ -2814,7 +2815,6 @@ "site_abandonment_survey_question_7_label": "Sim, por favor entre em contato.", "site_abandonment_survey_question_8_headline": "Por favor, compartilha seu e-mail:", "site_abandonment_survey_question_9_headline": "Algum comentário ou sugestão a mais?", - "skip": "Pular", "smileys_survey_name": "Pesquisa de Smileys", "smileys_survey_question_1_headline": "O que você tá achando do $[projectName]?", "smileys_survey_question_1_lower_label": "Não tá bom", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index ee53aa937c..7acce9c71f 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -1188,9 +1188,9 @@ "add": "Adicionar +", "add_a_delay_or_auto_close_the_survey": "Adicionar um atraso ou fechar automaticamente o inquérito", "add_a_four_digit_pin": "Adicione um PIN de quatro dígitos", - "add_a_new_question_to_your_survey": "Adicionar uma nova pergunta ao seu inquérito", "add_a_variable_to_calculate": "Adicionar uma variável para calcular", "add_action_below": "Adicionar ação abaixo", + "add_block": "Adicionar bloco", "add_choice_below": "Adicionar escolha abaixo", "add_color_coding": "Adicionar codificação de cores", "add_color_coding_description": "Adicionar códigos de cores vermelho, laranja e verde às opções.", @@ -1211,7 +1211,6 @@ "add_other": "Adicionar \"Outro\"", "add_photo_or_video": "Adicionar foto ou vídeo", "add_pin": "Adicionar PIN", - "add_question": "Adicionar pergunta", "add_question_below": "Adicionar pergunta abaixo", "add_row": "Adicionar linha", "add_variable": "Adicionar variável", @@ -1239,12 +1238,12 @@ "automatically_mark_the_survey_as_complete_after": "Marcar automaticamente o inquérito como concluído após", "back_button_label": "Rótulo do botão \"Voltar\"", "background_styling": "Estilo de Fundo", + "block_deleted": "Bloco eliminado.", + "block_duplicated": "Bloco duplicado.", "bold": "Negrito", "brand_color": "Cor da marca", "brightness": "Brilho", "button_label": "Rótulo do botão", - "button_to_continue_in_survey": "Botão para continuar na pesquisa", - "button_to_link_to_external_url": "Botão para ligar a URL externa", "button_url": "URL do botão", "cal_username": "Nome de utilizador do Cal.com ou nome de utilizador/evento", "calculate": "Calcular", @@ -1283,6 +1282,7 @@ "character_limit_toggle_title": "Adicionar limites de caracteres", "checkbox_label": "Rótulo da Caixa de Seleção", "choose_the_actions_which_trigger_the_survey": "Escolha as ações que desencadeiam o inquérito.", + "choose_the_first_question_on_your_block": "Escolha a primeira pergunta no seu bloco", "choose_where_to_run_the_survey": "Escolha onde realizar o inquérito.", "city": "Cidade", "close_survey_on_response_limit": "Fechar inquérito no limite de respostas", @@ -1311,6 +1311,7 @@ "date_format": "Formato da data", "days_before_showing_this_survey_again": "dias antes de mostrar este inquérito novamente.", "decide_how_often_people_can_answer_this_survey": "Decida com que frequência as pessoas podem responder a este inquérito.", + "delete_block": "Eliminar bloco", "delete_choice": "Eliminar escolha", "disable_the_visibility_of_survey_progress": "Desativar a visibilidade do progresso da pesquisa.", "display_an_estimate_of_completion_time_for_survey": "Mostrar uma estimativa do tempo de conclusão do inquérito", @@ -1322,6 +1323,7 @@ "does_not_include_all_of": "Não inclui todos de", "does_not_include_one_of": "Não inclui um de", "does_not_start_with": "Não começa com", + "duplicate_block": "Duplicar bloco", "edit_link": "Editar link", "edit_recall": "Editar Lembrete", "edit_translations": "Editar traduções {lang}", @@ -1578,7 +1580,6 @@ "show_to_x_percentage_of_targeted_users": "Mostrar a {percentage}% dos utilizadores alvo", "simple": "Simples", "six_points": "6 pontos", - "skip_button_label": "Rótulo do botão Ignorar", "smiley": "Sorridente", "spam_protection_note": "A proteção contra spam não funciona para inquéritos exibidos com os SDKs iOS, React Native e Android. Isso irá quebrar o inquérito.", "spam_protection_threshold_description": "Defina um valor entre 0 e 1, respostas abaixo deste valor serão rejeitadas.", @@ -2814,7 +2815,6 @@ "site_abandonment_survey_question_7_label": "Sim, por favor entre em contacto.", "site_abandonment_survey_question_8_headline": "Por favor, partilhe o seu endereço de email:", "site_abandonment_survey_question_9_headline": "Algum comentário ou sugestão adicional?", - "skip": "Saltar", "smileys_survey_name": "Inquérito Sorridente", "smileys_survey_question_1_headline": "Como gosta de $[projectName]?", "smileys_survey_question_1_lower_label": "Não é bom", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index 8478dbd87b..637f7468c1 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -1188,9 +1188,9 @@ "add": "Adaugă +", "add_a_delay_or_auto_close_the_survey": "Adăugați o întârziere sau închideți automat sondajul", "add_a_four_digit_pin": "Adăugați un cod PIN din patru cifre", - "add_a_new_question_to_your_survey": "Adaugă o nouă întrebare la sondajul tău", "add_a_variable_to_calculate": "Adaugă o variabilă pentru calcul", "add_action_below": "Adăugați acțiune mai jos", + "add_block": "Adaugă bloc", "add_choice_below": "Adaugă opțiunea de mai jos", "add_color_coding": "Adăugați codificare color", "add_color_coding_description": "Adăugați coduri de culoare roșu, portocaliu și verde la opțiuni.", @@ -1211,7 +1211,6 @@ "add_other": "Adăugați \"Altele\"", "add_photo_or_video": "Adaugă fotografie sau video", "add_pin": "Adaugă PIN", - "add_question": "Adaugă întrebare", "add_question_below": "Adaugă întrebare mai jos", "add_row": "Adăugați rând", "add_variable": "Adaugă variabilă", @@ -1239,12 +1238,12 @@ "automatically_mark_the_survey_as_complete_after": "Marcați automat sondajul ca finalizat după", "back_button_label": "Etichetă buton \"Înapoi\"", "background_styling": "Stilizare fundal", + "block_deleted": "Bloc șters.", + "block_duplicated": "Bloc duplicat.", "bold": "Îngroșat", "brand_color": "Culoarea brandului", "brightness": "Luminozitate", "button_label": "Etichetă buton", - "button_to_continue_in_survey": "Buton pentru a continua în sondaj", - "button_to_link_to_external_url": "Buton pentru a face legătura la un URL extern", "button_url": "URL Buton", "cal_username": "Utilizator Cal.com sau utilizator/eveniment", "calculate": "Calculați", @@ -1283,6 +1282,7 @@ "character_limit_toggle_title": "Adăugați limite de caractere", "checkbox_label": "Etichetă casetă de selectare", "choose_the_actions_which_trigger_the_survey": "Alegeți acțiunile care declanșează sondajul.", + "choose_the_first_question_on_your_block": "Alege prima întrebare din blocul tău", "choose_where_to_run_the_survey": "Alegeți unde să rulați chestionarul.", "city": "Oraș", "close_survey_on_response_limit": "Închideți sondajul la limită de răspunsuri", @@ -1311,6 +1311,7 @@ "date_format": "Format dată", "days_before_showing_this_survey_again": "zile înainte de a afișa din nou acest sondaj.", "decide_how_often_people_can_answer_this_survey": "Decide cât de des pot răspunde oamenii la acest sondaj", + "delete_block": "Șterge blocul", "delete_choice": "Șterge alegerea", "disable_the_visibility_of_survey_progress": "Dezactivați vizibilitatea progresului sondajului", "display_an_estimate_of_completion_time_for_survey": "Afișează o estimare a timpului de finalizare pentru sondaj", @@ -1322,6 +1323,7 @@ "does_not_include_all_of": "Nu include toate", "does_not_include_one_of": "Nu include una dintre", "does_not_start_with": "Nu începe cu", + "duplicate_block": "Duplicați blocul", "edit_link": "Editare legătură", "edit_recall": "Editează Referințele", "edit_translations": "Editează traducerile {lang}", @@ -1578,7 +1580,6 @@ "show_to_x_percentage_of_targeted_users": "Afișați la {percentage}% din utilizatorii vizați", "simple": "Simplu", "six_points": "6 puncte", - "skip_button_label": "Etichetă buton \"Omitere\"", "smiley": "Smiley", "spam_protection_note": "Protecția împotriva spamului nu funcționează pentru sondajele afișate folosind SDK-urile iOS, React Native și Android. Va întrerupe sondajul.", "spam_protection_threshold_description": "Setați valoarea între 0 și 1, răspunsurile sub această valoare vor fi respinse.", @@ -2814,7 +2815,6 @@ "site_abandonment_survey_question_7_label": "Da, vă rog să-mi trimiteți mesaj.", "site_abandonment_survey_question_8_headline": "Vă rugăm să împărtășiți adresa de email:", "site_abandonment_survey_question_9_headline": "Comentarii sau sugestii suplimentare?", - "skip": "Omite", "smileys_survey_name": "Sondaj Smileys", "smileys_survey_question_1_headline": "Cum îți place $[projectName]?", "smileys_survey_question_1_lower_label": "Nu este bine", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index 76c3b24e28..56f8b14413 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -1188,9 +1188,9 @@ "add": "添加 +", "add_a_delay_or_auto_close_the_survey": "添加 延迟 或 自动 关闭 调查", "add_a_four_digit_pin": "添加 一个 四 位 数 PIN", - "add_a_new_question_to_your_survey": "添加一个新问题到您的调查中", "add_a_variable_to_calculate": "添加 变量 以 计算", "add_action_below": "在下面添加操作", + "add_block": "添加区块", "add_choice_below": "在下方添加选项", "add_color_coding": "添加 颜色 编码", "add_color_coding_description": "添加 红色 、橙色 和 绿色 颜色 编码 到 选项。", @@ -1211,7 +1211,6 @@ "add_other": "添加 \"其他\"", "add_photo_or_video": "添加 照片 或 视频", "add_pin": "添加 PIN", - "add_question": "添加问题", "add_question_below": "在下面 添加 问题", "add_row": "添加 行", "add_variable": "添加 变量", @@ -1239,12 +1238,12 @@ "automatically_mark_the_survey_as_complete_after": "自动 标记 调查 为 完成 在", "back_button_label": "\"返回\" 按钮标签", "background_styling": "背景 样式", + "block_deleted": "区块已删除。", + "block_duplicated": "区块已复制。", "bold": "粗体", "brand_color": "品牌 颜色", "brightness": "亮度", "button_label": "按钮标签", - "button_to_continue_in_survey": "按钮 继续 调查", - "button_to_link_to_external_url": "按钮 链接 到 外部 URL", "button_url": "按钮 URL", "cal_username": "Cal.com 用户名 或 用户名/事件", "calculate": "计算", @@ -1283,6 +1282,7 @@ "character_limit_toggle_title": "添加 字符限制", "checkbox_label": "复选框 标签", "choose_the_actions_which_trigger_the_survey": "选择 触发 调查 的 动作 。", + "choose_the_first_question_on_your_block": "选择区块中的第一个问题", "choose_where_to_run_the_survey": "选择 调查 运行 的 位置 。", "city": "城市", "close_survey_on_response_limit": "在响应限制时关闭 调查", @@ -1311,6 +1311,7 @@ "date_format": "日期格式", "days_before_showing_this_survey_again": "显示 此 调查 之前 的 天数。", "decide_how_often_people_can_answer_this_survey": "决定 人 可以 回答 这份 调查 的 频率 。", + "delete_block": "删除区块", "delete_choice": "删除 选择", "disable_the_visibility_of_survey_progress": "禁用问卷 进度 的可见性。", "display_an_estimate_of_completion_time_for_survey": "显示 调查 预计 完成 时间", @@ -1322,6 +1323,7 @@ "does_not_include_all_of": "不包括所有 ", "does_not_include_one_of": "不包括一 个", "does_not_start_with": "不 以 开头", + "duplicate_block": "复制区块", "edit_link": "编辑 链接", "edit_recall": "编辑 调用", "edit_translations": "编辑 {lang} 翻译", @@ -1578,7 +1580,6 @@ "show_to_x_percentage_of_targeted_users": "显示 给 {percentage}% 的 目标 用户", "simple": "简单", "six_points": "6 分", - "skip_button_label": "\"跳过\" 按钮标签", "smiley": "笑脸", "spam_protection_note": "垃圾 邮件 保护 对于 与 iOS 、 React Native 和 Android SDK 一起 显示 的 调查 无效 。 它 将 破坏 调查。", "spam_protection_threshold_description": "设置 值 在 0 和 1 之间,响应 低于 此 值 将 被 拒绝。", @@ -2814,7 +2815,6 @@ "site_abandonment_survey_question_7_label": "是 的, 请 联系 我。", "site_abandonment_survey_question_8_headline": "请 分享您的 电子邮件地址:", "site_abandonment_survey_question_9_headline": "还有其他的意见或建议吗?", - "skip": "跳过", "smileys_survey_name": "笑脸 调查", "smileys_survey_question_1_headline": "您 如何 喜欢 $[projectName]?", "smileys_survey_question_1_lower_label": "不 好", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 795e8221ef..139c48a5f6 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -1188,9 +1188,9 @@ "add": "新增 +", "add_a_delay_or_auto_close_the_survey": "新增延遲或自動關閉問卷", "add_a_four_digit_pin": "新增四位數 PIN 碼", - "add_a_new_question_to_your_survey": "在您的問卷中新增一個新問題", "add_a_variable_to_calculate": "新增要計算的變數", "add_action_below": "在下方新增操作", + "add_block": "新增區塊", "add_choice_below": "在下方新增選項", "add_color_coding": "新增顏色編碼", "add_color_coding_description": "為選項新增紅色、橘色和綠色顏色代碼。", @@ -1211,7 +1211,6 @@ "add_other": "新增「其他」", "add_photo_or_video": "新增照片或影片", "add_pin": "新增 PIN 碼", - "add_question": "新增問題", "add_question_below": "在下方新增問題", "add_row": "新增列", "add_variable": "新增變數", @@ -1239,12 +1238,12 @@ "automatically_mark_the_survey_as_complete_after": "在指定時間後自動將問卷標記為完成", "back_button_label": "「返回」按鈕標籤", "background_styling": "背景樣式設定", + "block_deleted": "區塊已刪除。", + "block_duplicated": "區塊已複製。", "bold": "粗體", "brand_color": "品牌顏色", "brightness": "亮度", "button_label": "按鈕標籤", - "button_to_continue_in_survey": "問卷中繼續的按鈕", - "button_to_link_to_external_url": "連結到外部網址的按鈕", "button_url": "按鈕網址", "cal_username": "Cal.com 使用者名稱或使用者名稱/事件", "calculate": "計算", @@ -1283,6 +1282,7 @@ "character_limit_toggle_title": "新增字元限制", "checkbox_label": "核取方塊標籤", "choose_the_actions_which_trigger_the_survey": "選擇觸發問卷的操作。", + "choose_the_first_question_on_your_block": "選擇此區塊的第一個問題", "choose_where_to_run_the_survey": "選擇在哪裡執行問卷。", "city": "城市", "close_survey_on_response_limit": "在回應次數上限關閉問卷", @@ -1311,6 +1311,7 @@ "date_format": "日期格式", "days_before_showing_this_survey_again": "天後再次顯示此問卷。", "decide_how_often_people_can_answer_this_survey": "決定人們可以回答此問卷的頻率。", + "delete_block": "刪除區塊", "delete_choice": "刪除選項", "disable_the_visibility_of_survey_progress": "停用問卷進度的可見性。", "display_an_estimate_of_completion_time_for_survey": "顯示問卷的估計完成時間", @@ -1322,6 +1323,7 @@ "does_not_include_all_of": "不包含全部", "does_not_include_one_of": "不包含其中之一", "does_not_start_with": "不以...開頭", + "duplicate_block": "複製區塊", "edit_link": "編輯 連結", "edit_recall": "編輯回憶", "edit_translations": "編輯 '{'language'}' 翻譯", @@ -1578,7 +1580,6 @@ "show_to_x_percentage_of_targeted_users": "顯示給 '{'percentage'}'% 的目標使用者", "simple": "簡單", "six_points": "6 分", - "skip_button_label": "「跳過」按鈕標籤", "smiley": "表情符號", "spam_protection_note": "垃圾郵件保護不適用於使用 iOS、React Native 和 Android SDK 顯示的問卷。它會破壞問卷。", "spam_protection_threshold_description": "設置值在 0 和 1 之間,低於此值的回應將被拒絕。", @@ -2814,7 +2815,6 @@ "site_abandonment_survey_question_7_label": "是的,請聯絡我。", "site_abandonment_survey_question_8_headline": "請分享您的電子郵件地址:", "site_abandonment_survey_question_9_headline": "任何其他意見或建議?", - "skip": "跳過", "smileys_survey_name": "表情符號問卷", "smileys_survey_question_1_headline": "您覺得 {projectName} 如何?", "smileys_survey_question_1_lower_label": "不好", 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 e9957bc5c6..a17b3a469f 100644 --- a/apps/web/modules/survey/components/question-form-input/index.tsx +++ b/apps/web/modules/survey/components/question-form-input/index.tsx @@ -6,14 +6,12 @@ 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 { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { - TSurvey, - TSurveyEndScreenCard, - TSurveyQuestion, - TSurveyQuestionChoice, - TSurveyRedirectUrlCard, -} from "@formbricks/types/surveys/types"; + TSurveyElement, + TSurveyElementChoice, + TSurveyElementTypeEnum, +} from "@formbricks/types/surveys/elements"; +import { TSurvey, TSurveyEndScreenCard, TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils"; import { useSyncScroll } from "@/lib/utils/hooks/useSyncScroll"; @@ -43,10 +41,10 @@ interface QuestionFormInputProps { value: TI18nString | undefined; localSurvey: TSurvey; questionIdx: number; - updateQuestion?: (questionIdx: number, data: Partial) => void; + updateQuestion?: (questionIdx: number, data: Partial) => void; updateSurvey?: (data: Partial | Partial) => void; - updateChoice?: (choiceIdx: number, data: Partial) => void; - updateMatrixLabel?: (index: number, type: "row" | "column", data: Partial) => void; + updateChoice?: (choiceIdx: number, data: Partial) => void; + updateMatrixLabel?: (index: number, type: "row" | "column", matrixLabel: TI18nString) => void; isInvalid: boolean; selectedLanguageCode: string; setSelectedLanguageCode: (languageCode: string) => void; diff --git a/apps/web/modules/survey/editor/components/add-question-button.tsx b/apps/web/modules/survey/editor/components/add-question-button.tsx index 8632beac9b..91bb4ced5e 100644 --- a/apps/web/modules/survey/editor/components/add-question-button.tsx +++ b/apps/web/modules/survey/editor/components/add-question-button.tsx @@ -42,15 +42,14 @@ export const AddQuestionButton = ({ addQuestion, project, isCxMode }: AddQuestio
-

{t("environments.surveys.edit.add_question")}

+

{t("environments.surveys.edit.add_block")}

- {t("environments.surveys.edit.add_a_new_question_to_your_survey")} + {t("environments.surveys.edit.choose_the_first_question_on_your_block")}

- {/*
*/} {availableQuestionTypes.map((questionType) => ( + + + {Object.entries(availableQuestionTypes).map(([type, name]) => ( + handleAddQuestion(type)}> + {QUESTIONS_ICON_MAP[type]} + {name} + + ))} + + + ); +}; diff --git a/apps/web/modules/survey/editor/components/address-question-form.tsx b/apps/web/modules/survey/editor/components/address-question-form.tsx index 4b5caecd24..5b94e71526 100644 --- a/apps/web/modules/survey/editor/components/address-question-form.tsx +++ b/apps/web/modules/survey/editor/components/address-question-form.tsx @@ -4,7 +4,8 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; import { PlusIcon } from "lucide-react"; import { type JSX, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { TSurvey, TSurveyAddressQuestion } from "@formbricks/types/surveys/types"; +import { TSurveyAddressElement } from "@formbricks/types/surveys/elements"; +import { TSurvey } 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"; @@ -13,9 +14,9 @@ import { QuestionToggleTable } from "@/modules/ui/components/question-toggle-tab interface AddressQuestionFormProps { localSurvey: TSurvey; - question: TSurveyAddressQuestion; + question: TSurveyAddressElement; questionIdx: number; - updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; + updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; isInvalid: boolean; selectedLanguageCode: string; setSelectedLanguageCode: (language: string) => void; diff --git a/apps/web/modules/survey/editor/components/advanced-settings.tsx b/apps/web/modules/survey/editor/components/advanced-settings.tsx index 7173ab27c9..69180984e8 100644 --- a/apps/web/modules/survey/editor/components/advanced-settings.tsx +++ b/apps/web/modules/survey/editor/components/advanced-settings.tsx @@ -32,14 +32,15 @@ export const AdvancedSettings = ({ return (
- + /> */} void; + updateQuestion: (questionIdx: number, updatedAttributes: any) => void; + updateBlockLogic: (questionIdx: number, logic: TSurveyBlockLogic[]) => void; + updateBlockLogicFallback: (questionIdx: number, logicFallback: string | undefined) => void; + updateBlockButtonLabel: ( + blockIndex: number, + labelKey: "buttonLabel" | "backButtonLabel", + labelValue: TI18nString | undefined + ) => void; + deleteQuestion: (questionIdx: number) => void; + duplicateQuestion: (questionIdx: number) => void; + activeQuestionId: string | null; + setActiveQuestionId: (questionId: string | null) => void; + lastQuestion: boolean; + lastElementIndex: number; + selectedLanguageCode: string; + setSelectedLanguageCode: (language: string) => void; + invalidQuestions?: string[]; + addQuestion: (question: any, index?: number) => void; + isFormbricksCloud: boolean; + isCxMode: boolean; + locale: TUserLocale; + responseCount: number; + onAlertTrigger: () => void; + isStorageConfigured: boolean; + isExternalUrlsAllowed: boolean; + setLocalSurvey: (survey: TSurvey) => void; + duplicateBlock: (blockId: string) => void; + deleteBlock: (blockId: string) => void; + moveBlock: (blockId: string, direction: "up" | "down") => void; + addElementToBlock: (element: TSurveyElement, blockId: string, afterElementIdx: number) => void; + totalBlocks: number; +} + +export const BlockCard = ({ + localSurvey, + project, + block, + blockIdx, + moveQuestion, + updateQuestion, + updateBlockLogic, + updateBlockLogicFallback, + updateBlockButtonLabel, + duplicateQuestion, + deleteQuestion, + activeQuestionId, + setActiveQuestionId, + lastQuestion, + lastElementIndex, + selectedLanguageCode, + setSelectedLanguageCode, + invalidQuestions, + addQuestion, + isFormbricksCloud, + isCxMode, + locale, + responseCount, + onAlertTrigger, + isStorageConfigured = true, + isExternalUrlsAllowed, + setLocalSurvey, + duplicateBlock, + deleteBlock, + moveBlock, + addElementToBlock, + totalBlocks, +}: BlockCardProps) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: block.id, + }); + const { t } = useTranslation(); + const QUESTIONS_ICON_MAP = getQuestionIconMap(t); + + // Block-level properties + const blockName = block.name || `Block ${blockIdx + 1}`; + const hasMultipleElements = block.elements.length > 1; + const blockLogic = block.logic ?? []; + + // Check if any element in this block is currently active + const isBlockOpen = block.elements.some((element) => element.id === activeQuestionId); + + const [openAdvanced, setOpenAdvanced] = useState(blockLogic.length > 0); + const [parent] = useAutoAnimate(); + + // Get button labels from the block + const blockButtonLabel = block.buttonLabel; + const blockBackButtonLabel = block.backButtonLabel; + + const updateEmptyButtonLabels = ( + labelKey: "buttonLabel" | "backButtonLabel", + labelValue: TI18nString, + skipBlockIndex: number + ) => { + // Update button labels for all blocks except the one at skipBlockIndex + localSurvey.blocks.forEach((block, index) => { + if (index === skipBlockIndex) return; + const currentLabel = block[labelKey]; + if (!currentLabel || currentLabel[selectedLanguageCode]?.trim() === "") { + updateBlockButtonLabel(index, labelKey, labelValue); + } + }); + }; + + const getElementHeadline = ( + element: TSurveyElement, + languageCode: string + ): (string | React.ReactElement)[] | string | undefined => { + const headlineData = recallToHeadline(element.headline, localSurvey, true, languageCode); + const headlineText = headlineData[languageCode]; + if (headlineText) { + return formatTextWithSlashes(getTextContent(headlineText ?? "")); + } + return getTSurveyQuestionTypeEnumName(element.type, t); + }; + + const shouldShowCautionAlert = (elementType: TSurveyElementTypeEnum): boolean => { + return ( + responseCount > 0 && + [ + TSurveyElementTypeEnum.MultipleChoiceSingle, + TSurveyElementTypeEnum.MultipleChoiceMulti, + TSurveyElementTypeEnum.PictureSelection, + TSurveyElementTypeEnum.Rating, + TSurveyElementTypeEnum.NPS, + TSurveyElementTypeEnum.Ranking, + TSurveyElementTypeEnum.Matrix, + ].includes(elementType) + ); + }; + + const renderElementForm = ( + element: TSurveyElement, + questionIdx: number, + blockButtonLabel?: TI18nString + ) => { + switch (element.type) { + case TSurveyElementTypeEnum.OpenText: + return ( + + ); + case TSurveyElementTypeEnum.MultipleChoiceSingle: + return ( + + ); + case TSurveyElementTypeEnum.MultipleChoiceMulti: + return ( + + ); + case TSurveyElementTypeEnum.NPS: + return ( + + ); + case TSurveyElementTypeEnum.CTA: + return ( + + ); + case TSurveyElementTypeEnum.Rating: + return ( + + ); + case TSurveyElementTypeEnum.Consent: + return ( + + ); + case TSurveyElementTypeEnum.Date: + return ( + + ); + case TSurveyElementTypeEnum.PictureSelection: + return ( + + ); + case TSurveyElementTypeEnum.FileUpload: + return ( + + ); + case TSurveyElementTypeEnum.Cal: + return ( + + ); + case TSurveyElementTypeEnum.Matrix: + return ( + + ); + case TSurveyElementTypeEnum.Address: + return ( + + ); + case TSurveyElementTypeEnum.Ranking: + return ( + + ); + case TSurveyElementTypeEnum.ContactInfo: + return ( + + ); + default: + return null; + } + }; + + const style = { + transition: transition ?? "transform 100ms ease", + transform: CSS.Translate.toString(transform), + zIndex: isDragging ? 10 : 1, + }; + + return ( +
+
+
{blockIdx + 1}
+ + +
+
+ {/* Block header - shown when block has multiple elements */} + {hasMultipleElements && ( +
+
+

{blockName}

+

{block.elements.length} questions

+
+ duplicateBlock(block.id)} + onDelete={() => deleteBlock(block.id)} + onMoveUp={() => moveBlock(block.id, "up")} + onMoveDown={() => moveBlock(block.id, "down")} + /> +
+ )} + + {/* Render each element in the block */} + {block.elements.map((element, elementIndex) => { + // Calculate the actual question index in the flattened questions array + let questionIdx = 0; + for (let i = 0; i < blockIdx; i++) { + questionIdx += localSurvey.blocks[i].elements.length; + } + questionIdx += elementIndex; + + const isInvalid = invalidQuestions ? invalidQuestions.includes(element.id) : false; + const open = activeQuestionId === element.id; + + const getIsRequiredToggleDisabled = (): boolean => { + if (element.type === TSurveyElementTypeEnum.Address) { + const allFieldsAreOptional = [ + element.addressLine1, + element.addressLine2, + element.city, + element.state, + element.zip, + element.country, + ] + .filter((field) => field.show) + .every((field) => !field.required); + + if (allFieldsAreOptional) { + return true; + } + + return [ + element.addressLine1, + element.addressLine2, + element.city, + element.state, + element.zip, + element.country, + ] + .filter((field) => field.show) + .some((condition) => condition.required === true); + } + + if (element.type === TSurveyElementTypeEnum.ContactInfo) { + const allFieldsAreOptional = [ + element.firstName, + element.lastName, + element.email, + element.phone, + element.company, + ] + .filter((field) => field.show) + .every((field) => !field.required); + + if (allFieldsAreOptional) { + return true; + } + + return [element.firstName, element.lastName, element.email, element.phone, element.company] + .filter((field) => field.show) + .some((condition) => condition.required === true); + } + + return false; + }; + + const handleRequiredToggle = () => { + // Fix for NPS and Rating element having missing translations when buttonLabel is not removed + if (!element.required && (element.type === "nps" || element.type === "rating")) { + // Remove buttonLabel from the block when making NPS/Rating required + updateBlockButtonLabel(blockIdx, "buttonLabel", undefined); + updateQuestion(questionIdx, { required: true }); + } else { + updateQuestion(questionIdx, { required: !element.required }); + } + }; + + return ( +
0 && "border-t border-slate-200")}> + { + if (activeQuestionId !== element.id) { + setActiveQuestionId(element.id); + } else { + setActiveQuestionId(null); + } + }} + className="w-full"> + +
+
+
+
+ {QUESTIONS_ICON_MAP[element.type]} +
+
+ {hasMultipleElements && ( +

+ Question {elementIndex + 1} +

+ )} +

+ {getElementHeadline(element, selectedLanguageCode)} +

+ {!open && ( +

+ {element?.required + ? t("environments.surveys.edit.required") + : t("environments.surveys.edit.optional")} +

+ )} +
+
+
+ +
+ +
+
+
+ + {shouldShowCautionAlert(element.type) && ( + + {t("environments.surveys.edit.caution_text")} + onAlertTrigger()}>{t("common.learn_more")} + + )} + + {renderElementForm(element, questionIdx, blockButtonLabel)} +
+ + + {openAdvanced ? ( + + ) : ( + + )} + {openAdvanced + ? t("environments.surveys.edit.hide_advanced_settings") + : t("environments.surveys.edit.show_advanced_settings")} + + + + {element.type !== TSurveyElementTypeEnum.NPS && + element.type !== TSurveyElementTypeEnum.Rating && + element.type !== TSurveyElementTypeEnum.CTA ? ( +
+ {questionIdx !== 0 && ( + { + if (!blockBackButtonLabel) return; + let translatedBackButtonLabel = { + ...blockBackButtonLabel, + [selectedLanguageCode]: e.target.value, + }; + updateBlockButtonLabel( + blockIdx, + "backButtonLabel", + translatedBackButtonLabel + ); + updateEmptyButtonLabels( + "backButtonLabel", + translatedBackButtonLabel, + blockIdx + ); + }} + isStorageConfigured={isStorageConfigured} + /> + )} +
+ { + if (!blockButtonLabel) return; + let translatedNextButtonLabel = { + ...blockButtonLabel, + [selectedLanguageCode]: e.target.value, + }; + updateBlockButtonLabel(blockIdx, "buttonLabel", translatedNextButtonLabel); + // Don't propagate to last block + const lastBlockIndex = localSurvey.blocks.length - 1; + if (blockIdx !== lastBlockIndex) { + updateEmptyButtonLabels( + "buttonLabel", + translatedNextButtonLabel, + lastBlockIndex + ); + } + }} + locale={locale} + isStorageConfigured={isStorageConfigured} + /> +
+
+ ) : null} + {(element.type === TSurveyElementTypeEnum.Rating || + element.type === TSurveyElementTypeEnum.NPS) && + questionIdx !== 0 && ( +
+ { + if (!blockBackButtonLabel) return; + const translatedBackButtonLabel = { + ...blockBackButtonLabel, + [selectedLanguageCode]: e.target.value, + }; + updateBlockButtonLabel( + blockIdx, + "backButtonLabel", + translatedBackButtonLabel + ); + updateEmptyButtonLabels( + "backButtonLabel", + translatedBackButtonLabel, + blockIdx + ); + }} + isStorageConfigured={isStorageConfigured} + /> +
+ )} + + +
+
+
+
+ + {open && ( +
+ {element.type === "openText" && ( +
+ + { + e.stopPropagation(); + updateQuestion(questionIdx, { + longAnswer: + typeof element.longAnswer === "undefined" ? false : !element.longAnswer, + }); + }} + /> +
+ )} + { +
+ + { + e.stopPropagation(); + handleRequiredToggle(); + }} + /> +
+ } +
+ )} +
+
+ ); + })} + + {/* Add Question to Block button */} + +
+ +
+
+
+ ); +}; diff --git a/apps/web/modules/survey/editor/components/block-menu.tsx b/apps/web/modules/survey/editor/components/block-menu.tsx new file mode 100644 index 0000000000..0a1ccdd215 --- /dev/null +++ b/apps/web/modules/survey/editor/components/block-menu.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { ArrowDownIcon, ArrowUpIcon, CopyIcon, TrashIcon } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/modules/ui/components/button"; +import { TooltipRenderer } from "@/modules/ui/components/tooltip"; + +interface BlockMenuProps { + blockIndex: number; + isFirstBlock: boolean; + isLastBlock: boolean; + onDuplicate: () => void; + onDelete: () => void; + onMoveUp: () => void; + onMoveDown: () => void; +} + +export const BlockMenu = ({ + blockIndex, + isFirstBlock, + isLastBlock, + onDuplicate, + onDelete, + onMoveUp, + onMoveDown, +}: BlockMenuProps) => { + const { t } = useTranslation(); + + return ( +
+ + + + + + + + + + + + + + + +
+ ); +}; diff --git a/apps/web/modules/survey/editor/components/blocks-droppable.tsx b/apps/web/modules/survey/editor/components/blocks-droppable.tsx new file mode 100644 index 0000000000..4c9fe971d9 --- /dev/null +++ b/apps/web/modules/survey/editor/components/blocks-droppable.tsx @@ -0,0 +1,125 @@ +import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { useAutoAnimate } from "@formkit/auto-animate/react"; +import { Project } from "@prisma/client"; +import { TI18nString } from "@formbricks/types/i18n"; +import { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks"; +import { TSurveyElement } from "@formbricks/types/surveys/elements"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { TUserLocale } from "@formbricks/types/user"; +import { BlockCard } from "@/modules/survey/editor/components/block-card"; + +interface BlocksDroppableProps { + localSurvey: TSurvey; + setLocalSurvey: (survey: TSurvey) => void; + project: Project; + moveQuestion: (questionIndex: number, up: boolean) => void; + updateQuestion: (questionIdx: number, updatedAttributes: any) => void; + updateBlockLogic: (questionIdx: number, logic: TSurveyBlockLogic[]) => void; + updateBlockLogicFallback: (questionIdx: number, logicFallback: string | undefined) => void; + updateBlockButtonLabel: ( + blockIndex: number, + labelKey: "buttonLabel" | "backButtonLabel", + labelValue: TI18nString | undefined + ) => void; + deleteQuestion: (questionIdx: number) => void; + duplicateQuestion: (questionIdx: number) => void; + activeQuestionId: string | null; + setActiveQuestionId: (questionId: string | null) => void; + selectedLanguageCode: string; + setSelectedLanguageCode: (language: string) => void; + invalidQuestions: string[] | null; + addQuestion: (question: any, index?: number) => void; + isFormbricksCloud: boolean; + isCxMode: boolean; + locale: TUserLocale; + responseCount: number; + onAlertTrigger: () => void; + isStorageConfigured: boolean; + isExternalUrlsAllowed: boolean; + duplicateBlock: (blockId: string) => void; + deleteBlock: (blockId: string) => void; + moveBlock: (blockId: string, direction: "up" | "down") => void; + addElementToBlock: (element: TSurveyElement, blockId: string, afterElementIdx: number) => void; +} + +export const BlocksDroppable = ({ + activeQuestionId, + deleteQuestion, + duplicateQuestion, + invalidQuestions, + localSurvey, + setLocalSurvey, + moveQuestion, + project, + selectedLanguageCode, + setActiveQuestionId, + setSelectedLanguageCode, + updateQuestion, + updateBlockLogic, + updateBlockLogicFallback, + updateBlockButtonLabel, + addQuestion, + isFormbricksCloud, + isCxMode, + locale, + responseCount, + onAlertTrigger, + isStorageConfigured = true, + isExternalUrlsAllowed, + duplicateBlock, + deleteBlock, + moveBlock, + addElementToBlock, +}: BlocksDroppableProps) => { + const [parent] = useAutoAnimate(); + + return ( +
+ + {localSurvey.blocks.map((block, blockIdx) => { + // Check if this is the last block and has elements + const isLastBlock = blockIdx === localSurvey.blocks.length - 1; + const lastElementIndex = block.elements.length - 1; + + return ( + + ); + })} + +
+ ); +}; diff --git a/apps/web/modules/survey/editor/components/cal-question-form.tsx b/apps/web/modules/survey/editor/components/cal-question-form.tsx index 0a19ea3086..4b247eee68 100644 --- a/apps/web/modules/survey/editor/components/cal-question-form.tsx +++ b/apps/web/modules/survey/editor/components/cal-question-form.tsx @@ -3,7 +3,8 @@ import { PlusIcon } from "lucide-react"; import { type JSX, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { TSurvey, TSurveyCalQuestion } from "@formbricks/types/surveys/types"; +import { TSurveyCalElement } from "@formbricks/types/surveys/elements"; +import { TSurvey } 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"; @@ -14,9 +15,9 @@ import { Label } from "@/modules/ui/components/label"; interface CalQuestionFormProps { localSurvey: TSurvey; - question: TSurveyCalQuestion; + question: TSurveyCalElement; questionIdx: number; - updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; + updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; lastQuestion: boolean; selectedLanguageCode: string; setSelectedLanguageCode: (language: string) => void; diff --git a/apps/web/modules/survey/editor/components/consent-question-form.tsx b/apps/web/modules/survey/editor/components/consent-question-form.tsx index 4533bc05c3..be44d84085 100644 --- a/apps/web/modules/survey/editor/components/consent-question-form.tsx +++ b/apps/web/modules/survey/editor/components/consent-question-form.tsx @@ -2,15 +2,16 @@ import { type JSX } from "react"; import { useTranslation } from "react-i18next"; -import { TSurvey, TSurveyConsentQuestion } from "@formbricks/types/surveys/types"; +import { TSurveyConsentElement } from "@formbricks/types/surveys/elements"; +import { TSurvey } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { QuestionFormInput } from "@/modules/survey/components/question-form-input"; interface ConsentQuestionFormProps { localSurvey: TSurvey; - question: TSurveyConsentQuestion; + question: TSurveyConsentElement; questionIdx: number; - updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; + updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; selectedLanguageCode: string; setSelectedLanguageCode: (languageCode: string) => void; isInvalid: boolean; diff --git a/apps/web/modules/survey/editor/components/contact-info-question-form.tsx b/apps/web/modules/survey/editor/components/contact-info-question-form.tsx index 334b4557a5..5fd0f7a90d 100644 --- a/apps/web/modules/survey/editor/components/contact-info-question-form.tsx +++ b/apps/web/modules/survey/editor/components/contact-info-question-form.tsx @@ -4,7 +4,8 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; import { PlusIcon } from "lucide-react"; import { type JSX, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { TSurvey, TSurveyContactInfoQuestion } from "@formbricks/types/surveys/types"; +import { TSurveyContactInfoElement } from "@formbricks/types/surveys/elements"; +import { TSurvey } 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"; @@ -13,9 +14,9 @@ import { QuestionToggleTable } from "@/modules/ui/components/question-toggle-tab interface ContactInfoQuestionFormProps { localSurvey: TSurvey; - question: TSurveyContactInfoQuestion; + question: TSurveyContactInfoElement; questionIdx: number; - updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; + updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; lastQuestion: boolean; isInvalid: boolean; selectedLanguageCode: string; diff --git a/apps/web/modules/survey/editor/components/cta-question-form.tsx b/apps/web/modules/survey/editor/components/cta-question-form.tsx index 66c588cf1c..48a4c74d95 100644 --- a/apps/web/modules/survey/editor/components/cta-question-form.tsx +++ b/apps/web/modules/survey/editor/components/cta-question-form.tsx @@ -2,19 +2,18 @@ import { type JSX } from "react"; import { useTranslation } from "react-i18next"; -import { TSurvey, TSurveyCTAQuestion } from "@formbricks/types/surveys/types"; +import { TSurveyCTAElement } from "@formbricks/types/surveys/elements"; +import { TSurvey } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { QuestionFormInput } from "@/modules/survey/components/question-form-input"; import { Input } from "@/modules/ui/components/input"; import { Label } from "@/modules/ui/components/label"; -import { OptionsSwitch } from "@/modules/ui/components/options-switch"; -import { TooltipRenderer } from "@/modules/ui/components/tooltip"; interface CTAQuestionFormProps { localSurvey: TSurvey; - question: TSurveyCTAQuestion; + question: TSurveyCTAElement; questionIdx: number; - updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; + updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; lastQuestion: boolean; selectedLanguageCode: string; setSelectedLanguageCode: (languageCode: string) => void; @@ -38,13 +37,6 @@ export const CTAQuestionForm = ({ isExternalUrlsAllowed, }: CTAQuestionFormProps): JSX.Element => { const { t } = useTranslation(); - const options = [ - { - value: "internal", - label: t("environments.surveys.edit.button_to_continue_in_survey"), - }, - { value: "external", label: t("environments.surveys.edit.button_to_link_to_external_url") }, - ]; return (
@@ -80,49 +72,12 @@ export const CTAQuestionForm = ({ isExternalUrlsAllowed={isExternalUrlsAllowed} />
-
- - ({ - ...opt, - disabled: opt.value === "external" && !isExternalUrlsAllowed && !question.buttonExternal, - }))} - currentOption={question.buttonExternal ? "external" : "internal"} - handleOptionChange={(e) => { - const canSwitchToExternal = - e !== "external" || isExternalUrlsAllowed || question.buttonExternal; - if (canSwitchToExternal) { - updateQuestion(questionIdx, { buttonExternal: e === "external" }); - } - }} - /> - -
-
-
- {questionIdx !== 0 && ( - - )} +
+
-
-
- {question.buttonExternal && ( -
- -
+
+
- )} - - {!question.required && ( -
- -
- )} +
); }; diff --git a/apps/web/modules/survey/editor/components/date-question-form.tsx b/apps/web/modules/survey/editor/components/date-question-form.tsx index 1bcac2e5a9..c1fdae1ce9 100644 --- a/apps/web/modules/survey/editor/components/date-question-form.tsx +++ b/apps/web/modules/survey/editor/components/date-question-form.tsx @@ -4,7 +4,8 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; import { PlusIcon } from "lucide-react"; import { type JSX } from "react"; import { useTranslation } from "react-i18next"; -import { TSurvey, TSurveyDateQuestion } from "@formbricks/types/surveys/types"; +import { TSurveyDateElement } from "@formbricks/types/surveys/elements"; +import { TSurvey } 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"; @@ -14,9 +15,9 @@ import { OptionsSwitch } from "@/modules/ui/components/options-switch"; interface IDateQuestionFormProps { localSurvey: TSurvey; - question: TSurveyDateQuestion; + question: TSurveyDateElement; questionIdx: number; - updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; + updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; selectedLanguageCode: string; setSelectedLanguageCode: (language: string) => void; isInvalid: boolean; diff --git a/apps/web/modules/survey/editor/components/editor-card-menu.tsx b/apps/web/modules/survey/editor/components/editor-card-menu.tsx index c10150fa65..e8958cf0c6 100644 --- a/apps/web/modules/survey/editor/components/editor-card-menu.tsx +++ b/apps/web/modules/survey/editor/components/editor-card-menu.tsx @@ -5,13 +5,10 @@ import { Project } from "@prisma/client"; import { ArrowDownIcon, ArrowUpIcon, CopyIcon, EllipsisIcon, TrashIcon } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { - TSurvey, - TSurveyEndScreenCard, - TSurveyQuestion, - TSurveyQuestionTypeEnum, - TSurveyRedirectUrlCard, -} from "@formbricks/types/surveys/types"; +import { TI18nString } from "@formbricks/types/i18n"; +import { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks"; +import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; +import { TSurvey, TSurveyEndScreenCard, TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types"; import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils"; import { getCXQuestionNameMap, @@ -32,16 +29,25 @@ import { } from "@/modules/ui/components/dropdown-menu"; import { TooltipRenderer } from "@/modules/ui/components/tooltip"; +type EditorCardMenuSurveyElement = TSurveyElement & { + logic?: TSurveyBlockLogic[]; + buttonLabel?: TI18nString; + backButtonLabel?: TI18nString; +}; + interface EditorCardMenuProps { survey: TSurvey; cardIdx: number; lastCard: boolean; + blockId?: string; + elementIdx?: number; // Index of element within its block duplicateCard: (cardIdx: number) => void; deleteCard: (cardIdx: number) => void; moveCard: (cardIdx: number, up: boolean) => void; - card: TSurveyQuestion | TSurveyEndScreenCard | TSurveyRedirectUrlCard; + card: EditorCardMenuSurveyElement | TSurveyEndScreenCard | TSurveyRedirectUrlCard; updateCard: (cardIdx: number, updatedAttributes: any) => void; addCard: (question: any, index?: number) => void; + addCardToBlock?: (element: TSurveyElement, blockId: string, afterElementIdx: number) => void; cardType: "question" | "ending"; project?: Project; isCxMode?: boolean; @@ -51,6 +57,8 @@ export const EditorCardMenu = ({ survey, cardIdx, lastCard, + blockId, + elementIdx, duplicateCard, deleteCard, moveCard, @@ -58,6 +66,7 @@ export const EditorCardMenu = ({ card, updateCard, addCard, + addCardToBlock, cardType, isCxMode = false, }: EditorCardMenuProps) => { @@ -78,26 +87,24 @@ export const EditorCardMenu = ({ const availableQuestionTypes = isCxMode ? getCXQuestionNameMap(t) : getQuestionNameMap(t); - const changeQuestionType = (type?: TSurveyQuestionTypeEnum) => { + const changeQuestionType = (type?: TSurveyElementTypeEnum) => { if (!type) return; const { headline, required, subheader, imageUrl, videoUrl, buttonLabel, backButtonLabel } = - card as TSurveyQuestion; + card as EditorCardMenuSurveyElement; const questionDefaults = getQuestionDefaults(type, project, t); if ( - (type === TSurveyQuestionTypeEnum.MultipleChoiceSingle && - card.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) || - (type === TSurveyQuestionTypeEnum.MultipleChoiceMulti && - card.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle) || - (type === TSurveyQuestionTypeEnum.MultipleChoiceMulti && - card.type === TSurveyQuestionTypeEnum.Ranking) || - (type === TSurveyQuestionTypeEnum.Ranking && - card.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) || - (type === TSurveyQuestionTypeEnum.MultipleChoiceSingle && - card.type === TSurveyQuestionTypeEnum.Ranking) || - (type === TSurveyQuestionTypeEnum.Ranking && card.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle) + (type === TSurveyElementTypeEnum.MultipleChoiceSingle && + card.type === TSurveyElementTypeEnum.MultipleChoiceMulti) || + (type === TSurveyElementTypeEnum.MultipleChoiceMulti && + card.type === TSurveyElementTypeEnum.MultipleChoiceSingle) || + (type === TSurveyElementTypeEnum.MultipleChoiceMulti && card.type === TSurveyElementTypeEnum.Ranking) || + (type === TSurveyElementTypeEnum.Ranking && card.type === TSurveyElementTypeEnum.MultipleChoiceMulti) || + (type === TSurveyElementTypeEnum.MultipleChoiceSingle && + card.type === TSurveyElementTypeEnum.Ranking) || + (type === TSurveyElementTypeEnum.Ranking && card.type === TSurveyElementTypeEnum.MultipleChoiceSingle) ) { updateCard(cardIdx, { choices: card.choices, @@ -122,18 +129,23 @@ export const EditorCardMenu = ({ }); }; - const addQuestionCardBelow = (type: TSurveyQuestionTypeEnum) => { + const addQuestionCardBelow = (type: TSurveyElementTypeEnum) => { const questionDefaults = getQuestionDefaults(type, project, t); - addCard( - { - ...questionDefaults, - type, - id: createId(), - required: true, - }, - cardIdx + 1 - ); + const newQuestion = { + ...questionDefaults, + type, + id: createId(), + required: true, + }; + + // Add question to block or as new block + if (addCardToBlock && blockId && elementIdx !== undefined) { + // Pass blockId and element index within the block + addCardToBlock(newQuestion as TSurveyElement, blockId, elementIdx); + } else { + addCard(newQuestion, cardIdx + 1); + } const section = document.getElementById(`${card.id}`); section?.scrollIntoView({ behavior: "smooth", block: "end", inline: "end" }); @@ -228,15 +240,15 @@ export const EditorCardMenu = ({ { - setChangeToType(type as TSurveyQuestionTypeEnum); - if ((card as TSurveyQuestion).logic) { + setChangeToType(type as TSurveyElementTypeEnum); + if ((card as EditorCardMenuSurveyElement).logic) { setLogicWarningModal(true); return; } - changeQuestionType(type as TSurveyQuestionTypeEnum); + changeQuestionType(type as TSurveyElementTypeEnum); }} - icon={QUESTIONS_ICON_MAP[type as TSurveyQuestionTypeEnum]}> + icon={QUESTIONS_ICON_MAP[type as TSurveyElementTypeEnum]}> {name} ); @@ -270,10 +282,10 @@ export const EditorCardMenu = ({ onClick={(e) => { e.stopPropagation(); if (cardType === "question") { - addQuestionCardBelow(type as TSurveyQuestionTypeEnum); + addQuestionCardBelow(type as TSurveyElementTypeEnum); } }}> - {QUESTIONS_ICON_MAP[type as TSurveyQuestionTypeEnum]} + {QUESTIONS_ICON_MAP[type as TSurveyElementTypeEnum]} {name} ); diff --git a/apps/web/modules/survey/editor/components/file-upload-question-form.tsx b/apps/web/modules/survey/editor/components/file-upload-question-form.tsx index 71e2feea89..cbedb6ab18 100644 --- a/apps/web/modules/survey/editor/components/file-upload-question-form.tsx +++ b/apps/web/modules/survey/editor/components/file-upload-question-form.tsx @@ -8,7 +8,8 @@ import { type JSX, useMemo, useState } from "react"; import { toast } from "react-hot-toast"; import { useTranslation } from "react-i18next"; import { TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/storage"; -import { TSurvey, TSurveyFileUploadQuestion } from "@formbricks/types/surveys/types"; +import { TSurveyFileUploadElement } from "@formbricks/types/surveys/elements"; +import { TSurvey } 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"; @@ -20,9 +21,9 @@ import { useGetBillingInfo } from "@/modules/utils/hooks/useGetBillingInfo"; interface FileUploadFormProps { localSurvey: TSurvey; project?: Project; - question: TSurveyFileUploadQuestion; + question: TSurveyFileUploadElement; questionIdx: number; - updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; + updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; selectedLanguageCode: string; setSelectedLanguageCode: (languageCode: string) => void; isInvalid: boolean; 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 c16e15daaf..e13a741125 100644 --- a/apps/web/modules/survey/editor/components/matrix-question-form.tsx +++ b/apps/web/modules/survey/editor/components/matrix-question-form.tsx @@ -9,7 +9,8 @@ import { type JSX, useCallback } from "react"; import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; import { TI18nString } from "@formbricks/types/i18n"; -import { TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/surveys/types"; +import { TSurveyMatrixElement } from "@formbricks/types/surveys/elements"; +import { TSurvey } 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"; @@ -22,9 +23,9 @@ import { isLabelValidForAllLanguages } from "../lib/validation"; interface MatrixQuestionFormProps { localSurvey: TSurvey; - question: TSurveyMatrixQuestion; + question: TSurveyMatrixElement; questionIdx: number; - updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; + updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; selectedLanguageCode: string; setSelectedLanguageCode: (language: string) => void; isInvalid: boolean; 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 63d55c8dfb..6cb006ff59 100644 --- a/apps/web/modules/survey/editor/components/matrix-sortable-item.tsx +++ b/apps/web/modules/survey/editor/components/matrix-sortable-item.tsx @@ -6,18 +6,19 @@ import { GripVerticalIcon, TrashIcon } from "lucide-react"; import type { JSX } from "react"; import { useTranslation } from "react-i18next"; import { type TI18nString } from "@formbricks/types/i18n"; -import { TSurvey, TSurveyMatrixQuestion, TSurveyMatrixQuestionChoice } from "@formbricks/types/surveys/types"; +import { TSurveyMatrixElement, TSurveyMatrixElementChoice } from "@formbricks/types/surveys/elements"; +import { TSurvey } 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"; import { TooltipRenderer } from "@/modules/ui/components/tooltip"; interface MatrixSortableItemProps { - choice: TSurveyMatrixQuestionChoice; + choice: TSurveyMatrixElementChoice; type: "row" | "column"; index: number; localSurvey: TSurvey; - question: TSurveyMatrixQuestion; + question: TSurveyMatrixElement; questionIdx: number; updateMatrixLabel: (index: number, type: "row" | "column", matrixLabel: TI18nString) => void; onDelete: (index: number) => void; 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 5fb133959a..15a0a66e28 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 @@ -9,12 +9,8 @@ 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 { - TShuffleOption, - TSurvey, - TSurveyMultipleChoiceQuestion, - TSurveyQuestionTypeEnum, -} from "@formbricks/types/surveys/types"; +import { TSurveyElementTypeEnum, TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements"; +import { TShuffleOption, TSurvey } 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"; @@ -26,9 +22,9 @@ import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-sele interface MultipleChoiceQuestionFormProps { localSurvey: TSurvey; - question: TSurveyMultipleChoiceQuestion; + question: TSurveyMultipleChoiceElement; questionIdx: number; - updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; + updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; selectedLanguageCode: string; setSelectedLanguageCode: (language: string) => void; isInvalid: boolean; @@ -94,7 +90,7 @@ export const MultipleChoiceQuestionForm = ({ [question.choices] ); - const ensureSpecialChoicesOrder = (choices: TSurveyMultipleChoiceQuestion["choices"]) => { + const ensureSpecialChoicesOrder = (choices: TSurveyMultipleChoiceElement["choices"]) => { const otherChoice = choices.find((c) => c.id === "other"); const noneChoice = choices.find((c) => c.id === "none"); // [regularChoices, otherChoice, noneChoice] @@ -335,12 +331,12 @@ export const MultipleChoiceQuestionForm = ({ onClick={() => { updateQuestion(questionIdx, { type: - question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti - ? TSurveyQuestionTypeEnum.MultipleChoiceSingle - : TSurveyQuestionTypeEnum.MultipleChoiceMulti, + question.type === TSurveyElementTypeEnum.MultipleChoiceMulti + ? TSurveyElementTypeEnum.MultipleChoiceSingle + : TSurveyElementTypeEnum.MultipleChoiceMulti, }); }}> - {question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle + {question.type === TSurveyElementTypeEnum.MultipleChoiceSingle ? t("environments.surveys.edit.convert_to_multiple_choice") : t("environments.surveys.edit.convert_to_single_choice")} diff --git a/apps/web/modules/survey/editor/components/nps-question-form.tsx b/apps/web/modules/survey/editor/components/nps-question-form.tsx index 9ef5cb3888..4054f23d59 100644 --- a/apps/web/modules/survey/editor/components/nps-question-form.tsx +++ b/apps/web/modules/survey/editor/components/nps-question-form.tsx @@ -4,7 +4,9 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; import { PlusIcon } from "lucide-react"; import { type JSX } from "react"; import { useTranslation } from "react-i18next"; -import { TSurvey, TSurveyNPSQuestion } from "@formbricks/types/surveys/types"; +import { TI18nString } from "@formbricks/types/i18n"; +import { TSurveyNPSElement } from "@formbricks/types/surveys/elements"; +import { TSurvey } 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"; @@ -13,9 +15,9 @@ import { Button } from "@/modules/ui/components/button"; interface NPSQuestionFormProps { localSurvey: TSurvey; - question: TSurveyNPSQuestion; + question: TSurveyNPSElement; questionIdx: number; - updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; + updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; lastQuestion: boolean; selectedLanguageCode: string; setSelectedLanguageCode: (languageCode: string) => void; @@ -23,6 +25,7 @@ interface NPSQuestionFormProps { locale: TUserLocale; isStorageConfigured: boolean; isExternalUrlsAllowed?: boolean; + buttonLabel?: TI18nString; } export const NPSQuestionForm = ({ @@ -37,6 +40,7 @@ export const NPSQuestionForm = ({ locale, isStorageConfigured = true, isExternalUrlsAllowed, + buttonLabel, }: NPSQuestionFormProps): JSX.Element => { const { t } = useTranslation(); const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages); @@ -136,7 +140,7 @@ export const NPSQuestionForm = ({
) => void; + updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; lastQuestion: boolean; selectedLanguageCode: string; setSelectedLanguageCode: (language: string) => void; @@ -57,7 +54,7 @@ export const OpenQuestionForm = ({ const [showCharLimits, setShowCharLimits] = useState(question.inputType === "text"); - const handleInputChange = (inputType: TSurveyOpenTextQuestionInputType) => { + const handleInputChange = (inputType: TSurveyOpenTextElementInputType) => { const updatedAttributes = { inputType: inputType, placeholder: createI18nString(getPlaceholderByInputType(inputType), surveyLanguageCodes), @@ -238,7 +235,7 @@ export const OpenQuestionForm = ({ ); }; -const getPlaceholderByInputType = (inputType: TSurveyOpenTextQuestionInputType) => { +const getPlaceholderByInputType = (inputType: TSurveyOpenTextElementInputType) => { switch (inputType) { case "email": return "example@email.com"; diff --git a/apps/web/modules/survey/editor/components/picture-selection-form.tsx b/apps/web/modules/survey/editor/components/picture-selection-form.tsx index bf3ac8f439..4c079a47d1 100644 --- a/apps/web/modules/survey/editor/components/picture-selection-form.tsx +++ b/apps/web/modules/survey/editor/components/picture-selection-form.tsx @@ -5,7 +5,8 @@ import { createId } from "@paralleldrive/cuid2"; import { PlusIcon } from "lucide-react"; import { type JSX } from "react"; import { useTranslation } from "react-i18next"; -import { TSurvey, TSurveyPictureSelectionQuestion } from "@formbricks/types/surveys/types"; +import { TSurveyPictureSelectionElement } from "@formbricks/types/surveys/elements"; +import { TSurvey } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { cn } from "@/lib/cn"; import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils"; @@ -17,9 +18,9 @@ import { Switch } from "@/modules/ui/components/switch"; interface PictureSelectionFormProps { localSurvey: TSurvey; - question: TSurveyPictureSelectionQuestion; + question: TSurveyPictureSelectionElement; questionIdx: number; - updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; + updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; selectedLanguageCode: string; setSelectedLanguageCode: (language: string) => void; isInvalid: boolean; diff --git a/apps/web/modules/survey/editor/components/question-card.tsx b/apps/web/modules/survey/editor/components/question-card.tsx deleted file mode 100644 index a83f17fc2f..0000000000 --- a/apps/web/modules/survey/editor/components/question-card.tsx +++ /dev/null @@ -1,698 +0,0 @@ -"use client"; - -import { useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { useAutoAnimate } from "@formkit/auto-animate/react"; -import { Project } from "@prisma/client"; -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 { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks"; -import { TSurveyElement } from "@formbricks/types/surveys/elements"; -import { - TSurvey, - TSurveyQuestion, - TSurveyQuestionId, - TSurveyQuestionTypeEnum, -} from "@formbricks/types/surveys/types"; -import { getTextContent } from "@formbricks/types/surveys/validation"; -import { TUserLocale } from "@formbricks/types/user"; -import { cn } from "@/lib/cn"; -import { recallToHeadline } from "@/lib/utils/recall"; -import { QuestionFormInput } from "@/modules/survey/components/question-form-input"; -import { AddressQuestionForm } from "@/modules/survey/editor/components/address-question-form"; -import { AdvancedSettings } from "@/modules/survey/editor/components/advanced-settings"; -import { CalQuestionForm } from "@/modules/survey/editor/components/cal-question-form"; -import { ConsentQuestionForm } from "@/modules/survey/editor/components/consent-question-form"; -import { ContactInfoQuestionForm } from "@/modules/survey/editor/components/contact-info-question-form"; -import { CTAQuestionForm } from "@/modules/survey/editor/components/cta-question-form"; -import { DateQuestionForm } from "@/modules/survey/editor/components/date-question-form"; -import { EditorCardMenu } from "@/modules/survey/editor/components/editor-card-menu"; -import { FileUploadQuestionForm } from "@/modules/survey/editor/components/file-upload-question-form"; -import { MatrixQuestionForm } from "@/modules/survey/editor/components/matrix-question-form"; -import { MultipleChoiceQuestionForm } from "@/modules/survey/editor/components/multiple-choice-question-form"; -import { NPSQuestionForm } from "@/modules/survey/editor/components/nps-question-form"; -import { OpenQuestionForm } from "@/modules/survey/editor/components/open-question-form"; -import { PictureSelectionForm } from "@/modules/survey/editor/components/picture-selection-form"; -import { RankingQuestionForm } from "@/modules/survey/editor/components/ranking-question-form"; -import { RatingQuestionForm } from "@/modules/survey/editor/components/rating-question-form"; -import { findElementLocation } from "@/modules/survey/editor/lib/blocks"; -import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils"; -import { getQuestionIconMap, getTSurveyQuestionTypeEnumName } from "@/modules/survey/lib/questions"; -import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert"; -import { Label } from "@/modules/ui/components/label"; -import { Switch } from "@/modules/ui/components/switch"; - -interface QuestionCardProps { - localSurvey: TSurvey; - project: Project; - question: TSurveyQuestion; - questionIdx: number; - moveQuestion: (questionIndex: number, up: boolean) => void; - updateQuestion: (questionIdx: number, updatedAttributes: any) => void; - updateBlockLogic: (questionIdx: number, logic: TSurveyBlockLogic[]) => void; - updateBlockLogicFallback: (questionIdx: number, logicFallback: string | undefined) => void; - updateBlockButtonLabel: ( - blockIndex: number, - labelKey: "buttonLabel" | "backButtonLabel", - labelValue: TI18nString | undefined - ) => void; - deleteQuestion: (questionIdx: number) => void; - duplicateQuestion: (questionIdx: number) => void; - activeQuestionId: TSurveyQuestionId | null; - setActiveQuestionId: (questionId: TSurveyQuestionId | null) => void; - lastQuestion: boolean; - selectedLanguageCode: string; - setSelectedLanguageCode: (language: string) => void; - isInvalid: boolean; - addQuestion: (question: any, index?: number) => void; - isFormbricksCloud: boolean; - isCxMode: boolean; - locale: TUserLocale; - responseCount: number; - onAlertTrigger: () => void; - isStorageConfigured: boolean; - isExternalUrlsAllowed: boolean; -} - -export const QuestionCard = ({ - localSurvey, - project, - question, - questionIdx, - moveQuestion, - updateQuestion, - updateBlockLogic, - updateBlockLogicFallback, - updateBlockButtonLabel, - duplicateQuestion, - deleteQuestion, - activeQuestionId, - setActiveQuestionId, - lastQuestion, - selectedLanguageCode, - setSelectedLanguageCode, - isInvalid, - addQuestion, - isFormbricksCloud, - isCxMode, - locale, - responseCount, - onAlertTrigger, - isStorageConfigured = true, - isExternalUrlsAllowed, -}: QuestionCardProps) => { - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id: question.id, - }); - const { t } = useTranslation(); - const QUESTIONS_ICON_MAP = getQuestionIconMap(t); - const open = activeQuestionId === question.id; - - // Find the parent block for this question/element to get its logic - const { blockIndex: parentBlockIndex } = findElementLocation(localSurvey, question.id); - const parentBlock = parentBlockIndex !== -1 ? localSurvey.blocks[parentBlockIndex] : undefined; - const blockLogic = parentBlock?.logic ?? []; - - const [openAdvanced, setOpenAdvanced] = useState(blockLogic.length > 0); - const [parent] = useAutoAnimate(); - - // Get button labels from the parent block (not from element) - const blockButtonLabel = parentBlock?.buttonLabel; - const blockBackButtonLabel = parentBlock?.backButtonLabel; - - const updateEmptyButtonLabels = ( - labelKey: "buttonLabel" | "backButtonLabel", - labelValue: TI18nString, - skipBlockIndex: number - ) => { - // Update button labels for all blocks except the one at skipBlockIndex - localSurvey.blocks.forEach((block, index) => { - if (index === skipBlockIndex) return; - const currentLabel = block[labelKey]; - if (!currentLabel || currentLabel[selectedLanguageCode]?.trim() === "") { - updateBlockButtonLabel(index, labelKey, labelValue); - } - }); - }; - - const getIsRequiredToggleDisabled = (): boolean => { - if (question.type === TSurveyQuestionTypeEnum.Address) { - const allFieldsAreOptional = [ - question.addressLine1, - question.addressLine2, - question.city, - question.state, - question.zip, - question.country, - ] - .filter((field) => field.show) - .every((field) => !field.required); - - if (allFieldsAreOptional) { - return true; - } - - return [ - question.addressLine1, - question.addressLine2, - question.city, - question.state, - question.zip, - question.country, - ] - .filter((field) => field.show) - .some((condition) => condition.required === true); - } - - if (question.type === TSurveyQuestionTypeEnum.ContactInfo) { - const allFieldsAreOptional = [ - question.firstName, - question.lastName, - question.email, - question.phone, - question.company, - ] - .filter((field) => field.show) - .every((field) => !field.required); - - if (allFieldsAreOptional) { - return true; - } - - return [question.firstName, question.lastName, question.email, question.phone, question.company] - .filter((field) => field.show) - .some((condition) => condition.required === true); - } - - return false; - }; - - const handleRequiredToggle = () => { - // Fix for NPS and Rating questions having missing translations when buttonLabel is not removed - if (!question.required && (question.type === "nps" || question.type === "rating")) { - // Remove buttonLabel from the block when making NPS/Rating required - if (parentBlockIndex !== -1) { - updateBlockButtonLabel(parentBlockIndex, "buttonLabel", undefined); - } - updateQuestion(questionIdx, { required: true }); - } else { - updateQuestion(questionIdx, { required: !question.required }); - } - }; - - const style = { - transition: transition ?? "transform 100ms ease", - transform: CSS.Translate.toString(transform), - zIndex: isDragging ? 10 : 1, - }; - - return ( -
-
-
{QUESTIONS_ICON_MAP[question.type]}
- - -
- { - if (activeQuestionId !== question.id) { - setActiveQuestionId(question.id); - } else { - setActiveQuestionId(null); - } - }} - className="w-[95%] flex-1 rounded-r-lg border border-slate-200"> - -
-
-
-

- {recallToHeadline(question.headline, localSurvey, true, selectedLanguageCode)[ - selectedLanguageCode - ] - ? formatTextWithSlashes( - getTextContent( - recallToHeadline(question.headline, localSurvey, true, selectedLanguageCode)[ - selectedLanguageCode - ] ?? "" - ) - ) - : getTSurveyQuestionTypeEnumName(question.type, t)} -

- {!open && ( -

- {question?.required - ? t("environments.surveys.edit.required") - : t("environments.surveys.edit.optional")} -

- )} -
-
- -
- -
-
-
- - {responseCount > 0 && - [ - TSurveyQuestionTypeEnum.MultipleChoiceSingle, - TSurveyQuestionTypeEnum.MultipleChoiceMulti, - TSurveyQuestionTypeEnum.PictureSelection, - TSurveyQuestionTypeEnum.Rating, - TSurveyQuestionTypeEnum.NPS, - TSurveyQuestionTypeEnum.Ranking, - TSurveyQuestionTypeEnum.Matrix, - ].includes(question.type) ? ( - - {t("environments.surveys.edit.caution_text")} - onAlertTrigger()}>{t("common.learn_more")} - - ) : null} - {question.type === TSurveyQuestionTypeEnum.OpenText ? ( - - ) : question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle ? ( - - ) : question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ? ( - - ) : question.type === TSurveyQuestionTypeEnum.NPS ? ( - - ) : question.type === TSurveyQuestionTypeEnum.CTA ? ( - - ) : question.type === TSurveyQuestionTypeEnum.Rating ? ( - - ) : question.type === TSurveyQuestionTypeEnum.Consent ? ( - - ) : question.type === TSurveyQuestionTypeEnum.Date ? ( - - ) : question.type === TSurveyQuestionTypeEnum.PictureSelection ? ( - - ) : question.type === TSurveyQuestionTypeEnum.FileUpload ? ( - - ) : question.type === TSurveyQuestionTypeEnum.Cal ? ( - - ) : question.type === TSurveyQuestionTypeEnum.Matrix ? ( - - ) : question.type === TSurveyQuestionTypeEnum.Address ? ( - - ) : question.type === TSurveyQuestionTypeEnum.Ranking ? ( - - ) : question.type === TSurveyQuestionTypeEnum.ContactInfo ? ( - - ) : null} -
- - - {openAdvanced ? ( - - ) : ( - - )} - {openAdvanced - ? t("environments.surveys.edit.hide_advanced_settings") - : t("environments.surveys.edit.show_advanced_settings")} - - - - {question.type !== TSurveyQuestionTypeEnum.NPS && - question.type !== TSurveyQuestionTypeEnum.Rating && - question.type !== TSurveyQuestionTypeEnum.CTA ? ( -
- {questionIdx !== 0 && ( - { - if (!blockBackButtonLabel) return; - let translatedBackButtonLabel = { - ...blockBackButtonLabel, - [selectedLanguageCode]: e.target.value, - }; - if (parentBlockIndex === -1) return; - updateBlockButtonLabel( - parentBlockIndex, - "backButtonLabel", - translatedBackButtonLabel - ); - updateEmptyButtonLabels( - "backButtonLabel", - translatedBackButtonLabel, - parentBlockIndex - ); - }} - isStorageConfigured={isStorageConfigured} - /> - )} -
- { - if (!blockButtonLabel) return; - let translatedNextButtonLabel = { - ...blockButtonLabel, - [selectedLanguageCode]: e.target.value, - }; - if (parentBlockIndex === -1) return; - updateBlockButtonLabel(parentBlockIndex, "buttonLabel", translatedNextButtonLabel); - // Don't propagate to last block - const lastBlockIndex = localSurvey.blocks.length - 1; - if (parentBlockIndex !== lastBlockIndex) { - updateEmptyButtonLabels("buttonLabel", translatedNextButtonLabel, lastBlockIndex); - } - }} - locale={locale} - isStorageConfigured={isStorageConfigured} - /> -
-
- ) : null} - {(question.type === TSurveyQuestionTypeEnum.Rating || - question.type === TSurveyQuestionTypeEnum.NPS) && - questionIdx !== 0 && ( -
- { - if (!blockBackButtonLabel) return; - const translatedBackButtonLabel = { - ...blockBackButtonLabel, - [selectedLanguageCode]: e.target.value, - }; - if (parentBlockIndex === -1) return; - updateBlockButtonLabel( - parentBlockIndex, - "backButtonLabel", - translatedBackButtonLabel - ); - updateEmptyButtonLabels( - "backButtonLabel", - translatedBackButtonLabel, - parentBlockIndex - ); - }} - isStorageConfigured={isStorageConfigured} - /> -
- )} - - -
-
-
-
- - {open && ( -
- {question.type === "openText" && ( -
- - { - e.stopPropagation(); - updateQuestion(questionIdx, { - longAnswer: typeof question.longAnswer === "undefined" ? false : !question.longAnswer, - }); - }} - /> -
- )} - { -
- - { - e.stopPropagation(); - handleRequiredToggle(); - }} - /> -
- } -
- )} -
-
- ); -}; 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 798622f753..126a5cf972 100644 --- a/apps/web/modules/survey/editor/components/question-option-choice.tsx +++ b/apps/web/modules/survey/editor/components/question-option-choice.tsx @@ -6,12 +6,11 @@ import { GripVerticalIcon, PlusIcon, TrashIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; import { TI18nString } from "@formbricks/types/i18n"; import { - TSurvey, - TSurveyLanguage, - TSurveyMultipleChoiceQuestion, - TSurveyQuestionChoice, - TSurveyRankingQuestion, -} from "@formbricks/types/surveys/types"; + TSurveyElementChoice, + TSurveyMultipleChoiceElement, + TSurveyRankingElement, +} from "@formbricks/types/surveys/elements"; +import { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { cn } from "@/lib/cn"; import { createI18nString } from "@/lib/i18n/utils"; @@ -21,7 +20,7 @@ import { TooltipRenderer } from "@/modules/ui/components/tooltip"; import { isLabelValidForAllLanguages } from "../lib/validation"; interface ChoiceProps { - choice: TSurveyQuestionChoice; + choice: TSurveyElementChoice; choiceIdx: number; questionIdx: number; updateChoice: (choiceIdx: number, updatedAttributes: { label: TI18nString }) => void; @@ -32,10 +31,10 @@ interface ChoiceProps { selectedLanguageCode: string; setSelectedLanguageCode: (language: string) => void; surveyLanguages: TSurveyLanguage[]; - question: TSurveyMultipleChoiceQuestion | TSurveyRankingQuestion; + question: TSurveyMultipleChoiceElement | TSurveyRankingElement; updateQuestion: ( questionIdx: number, - updatedAttributes: Partial | Partial + updatedAttributes: Partial | Partial ) => void; surveyLanguageCodes: string[]; locale: TUserLocale; diff --git a/apps/web/modules/survey/editor/components/questions-droppable.tsx b/apps/web/modules/survey/editor/components/questions-droppable.tsx deleted file mode 100644 index 19c7c9574b..0000000000 --- a/apps/web/modules/survey/editor/components/questions-droppable.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; -import { useAutoAnimate } from "@formkit/auto-animate/react"; -import { Project } from "@prisma/client"; -import { useMemo } from "react"; -import { TI18nString } from "@formbricks/types/i18n"; -import { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks"; -import { TSurvey, TSurveyQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types"; -import { TUserLocale } from "@formbricks/types/user"; -import { QuestionCard } from "@/modules/survey/editor/components/question-card"; -import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils"; - -interface QuestionsDraggableProps { - localSurvey: TSurvey; - project: Project; - moveQuestion: (questionIndex: number, up: boolean) => void; - updateQuestion: (questionIdx: number, updatedAttributes: any) => void; - updateBlockLogic: (questionIdx: number, logic: TSurveyBlockLogic[]) => void; - updateBlockLogicFallback: (questionIdx: number, logicFallback: string | undefined) => void; - updateBlockButtonLabel: ( - blockIndex: number, - labelKey: "buttonLabel" | "backButtonLabel", - labelValue: TI18nString | undefined - ) => void; - deleteQuestion: (questionIdx: number) => void; - duplicateQuestion: (questionIdx: number) => void; - activeQuestionId: TSurveyQuestionId | null; - setActiveQuestionId: (questionId: TSurveyQuestionId | null) => void; - selectedLanguageCode: string; - setSelectedLanguageCode: (language: string) => void; - invalidQuestions: string[] | null; - addQuestion: (question: any, index?: number) => void; - isFormbricksCloud: boolean; - isCxMode: boolean; - locale: TUserLocale; - responseCount: number; - onAlertTrigger: () => void; - isStorageConfigured: boolean; - isExternalUrlsAllowed: boolean; -} - -export const QuestionsDroppable = ({ - activeQuestionId, - deleteQuestion, - duplicateQuestion, - invalidQuestions, - localSurvey, - moveQuestion, - project, - selectedLanguageCode, - setActiveQuestionId, - setSelectedLanguageCode, - updateQuestion, - updateBlockLogic, - updateBlockLogicFallback, - updateBlockButtonLabel, - addQuestion, - isFormbricksCloud, - isCxMode, - locale, - responseCount, - onAlertTrigger, - isStorageConfigured = true, - isExternalUrlsAllowed, -}: QuestionsDraggableProps) => { - const [parent] = useAutoAnimate(); - - // Derive questions from blocks for display - const questions = useMemo(() => getElementsFromBlocks(localSurvey.blocks), [localSurvey.blocks]); - - return ( -
- - {questions.map((question, questionIdx) => ( - - ))} - -
- ); -}; diff --git a/apps/web/modules/survey/editor/components/questions-view.tsx b/apps/web/modules/survey/editor/components/questions-view.tsx index 38425cb495..97f20313a8 100644 --- a/apps/web/modules/survey/editor/components/questions-view.tsx +++ b/apps/web/modules/survey/editor/components/questions-view.tsx @@ -19,8 +19,9 @@ import { TI18nString } from "@formbricks/types/i18n"; import { TSurveyQuota } from "@formbricks/types/quota"; import { TSurveyBlock, TSurveyBlockLogic, TSurveyBlockLogicAction } from "@formbricks/types/surveys/blocks"; import { findBlocksWithCyclicLogic } from "@formbricks/types/surveys/blocks-validation"; +import { TSurveyElement } from "@formbricks/types/surveys/elements"; import { type TConditionGroup, type TSingleCondition } from "@formbricks/types/surveys/logic"; -import { TSurvey, TSurveyQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types"; +import { TSurvey } from "@formbricks/types/surveys/types"; import { TUserLocale } from "@formbricks/types/user"; import { getDefaultEndingCard } from "@/app/lib/survey-builder"; import { addMultiLanguageLabels, extractLanguageCodes } from "@/lib/i18n/utils"; @@ -30,17 +31,20 @@ import { checkForEmptyFallBackValue, extractRecallInfo } from "@/lib/utils/recal import { MultiLanguageCard } from "@/modules/ee/multi-language-surveys/components/multi-language-card"; import { AddEndingCardButton } from "@/modules/survey/editor/components/add-ending-card-button"; import { AddQuestionButton } from "@/modules/survey/editor/components/add-question-button"; +import { BlocksDroppable } from "@/modules/survey/editor/components/blocks-droppable"; import { EditEndingCard } from "@/modules/survey/editor/components/edit-ending-card"; import { EditWelcomeCard } from "@/modules/survey/editor/components/edit-welcome-card"; import { HiddenFieldsCard } from "@/modules/survey/editor/components/hidden-fields-card"; -import { QuestionsDroppable } from "@/modules/survey/editor/components/questions-droppable"; import { SurveyVariablesCard } from "@/modules/survey/editor/components/survey-variables-card"; import { addBlock, + addElementToBlock, deleteBlock, - duplicateBlock, + deleteElementFromBlock, + duplicateBlock as duplicateBlockHelper, findElementLocation, - moveBlock, + moveBlock as moveBlockHelper, + moveElementInBlock, updateElementInBlock, } from "@/modules/survey/editor/lib/blocks"; import { findQuestionUsedInLogic, isUsedInQuota, isUsedInRecall } from "@/modules/survey/editor/lib/utils"; @@ -48,15 +52,15 @@ import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils"; import { isEndingCardValid, isWelcomeCardValid, - validateQuestion, - validateSurveyQuestionsInBatch, + validateElement, + validateSurveyElementsInBatch, } from "../lib/validation"; interface QuestionsViewProps { localSurvey: TSurvey; setLocalSurvey: React.Dispatch>; - activeQuestionId: TSurveyQuestionId | null; - setActiveQuestionId: (questionId: TSurveyQuestionId | null) => void; + activeQuestionId: string | null; + setActiveQuestionId: (questionId: string | null) => void; project: Project; projectLanguages: Language[]; invalidQuestions: string[] | null; @@ -229,35 +233,32 @@ export const QuestionsView = ({ } }, [localSurvey.welcomeCard, localSurvey.endings, surveyLanguages, invalidQuestions, setInvalidQuestions]); - // function to validate individual questions - const validateSurveyQuestion = (question: TSurveyQuestion) => { + // function to validate individual elements + const validateSurveyElement = (element: TSurveyElement) => { // prevent this function to execute further if user hasnt still tried to save the survey if (invalidQuestions === null) { return; } - const firstElement = localSurvey.blocks?.[0]?.elements[0]; - const isFirstQuestion = firstElement ? question.id === firstElement.id : false; - - if (validateQuestion(question as unknown as TSurveyQuestion, surveyLanguages, isFirstQuestion)) { + if (validateElement(element, surveyLanguages)) { const blocksWithCyclicLogic = findBlocksWithCyclicLogic(localSurvey.blocks); for (const blockId of blocksWithCyclicLogic) { const block = localSurvey.blocks.find((b) => b.id === blockId); if (block) { - const questionId = getQuestionIdFromBlockId(block); - if (questionId === question.id) { - setInvalidQuestions([...invalidQuestions, question.id]); + const elementId = getQuestionIdFromBlockId(block); + if (elementId === element.id) { + setInvalidQuestions([...invalidQuestions, element.id]); return; } } } - setInvalidQuestions(invalidQuestions.filter((id) => id !== question.id)); + setInvalidQuestions(invalidQuestions.filter((id) => id !== element.id)); return; } - setInvalidQuestions([...invalidQuestions, question.id]); + setInvalidQuestions([...invalidQuestions, element.id]); return; }; @@ -337,12 +338,12 @@ export const QuestionsView = ({ updatedSurvey = result.data; - // Validate the updated question - const updatedQuestion = updatedSurvey.blocks + // Validate the updated element + const updatedElement = updatedSurvey.blocks ?.flatMap((b) => b.elements) .find((q) => q.id === (cleanedAttributes.id ?? question.id)); - if (updatedQuestion) { - validateSurveyQuestion(updatedQuestion as unknown as TSurveyQuestion); + if (updatedElement) { + validateSurveyElement(updatedElement); } } @@ -466,22 +467,36 @@ export const QuestionsView = ({ }), })); - // Find and delete the block containing this question - const { blockId } = findElementLocation(localSurvey, questionId); - if (!blockId) return; + // Find the block containing this question + const { blockId, blockIndex } = findElementLocation(localSurvey, questionId); + if (!blockId || blockIndex === -1) return; - const result = deleteBlock(updatedSurvey, blockId); - if (!result.ok) { - toast.error(result.error.message); - return; + const block = updatedSurvey.blocks[blockIndex]; + + // If this is the only element in the block, delete the entire block + if (block.elements.length === 1) { + const result = deleteBlock(updatedSurvey, blockId); + if (!result.ok) { + toast.error(result.error.message); + return; + } + updatedSurvey = result.data; + } else { + // Otherwise, just remove this element from the block + const result = deleteElementFromBlock(updatedSurvey, blockId, questionId); + if (!result.ok) { + toast.error(result.error.message); + return; + } + updatedSurvey = result.data; } const firstEndingCard = localSurvey.endings[0]; - setLocalSurvey(result.data); + setLocalSurvey(updatedSurvey); delete internalQuestionIdMap[questionId]; if (questionId === activeQuestionIdTemp) { - const newQuestions = result.data.blocks?.flatMap((b) => b.elements) ?? []; + const newQuestions = updatedSurvey.blocks.flatMap((b) => b.elements) ?? []; if (questionIdx <= newQuestions.length && newQuestions.length > 0) { setActiveQuestionId(newQuestions[questionIdx % newQuestions.length].id); } else if (firstEndingCard) { @@ -496,36 +511,33 @@ export const QuestionsView = ({ const question = questions[questionIdx]; if (!question) return; - const { blockId } = findElementLocation(localSurvey, question.id); - if (!blockId) return; + const { blockId, blockIndex } = findElementLocation(localSurvey, question.id); + if (!blockId || blockIndex === -1) return; - const result = duplicateBlock(localSurvey, blockId); + // Create a duplicate of the element with a new ID + const newElementId = createId(); + const duplicatedElement = { ...question, id: newElementId }; + + // Add the duplicated element to the same block + const result = addElementToBlock(localSurvey, blockId, duplicatedElement); if (!result.ok) { toast.error(result.error.message); return; } - // The duplicated block has new element IDs, find the first one - const allBlocks = result.data.blocks ?? []; - const { blockIndex } = findElementLocation(localSurvey, question.id); - const duplicatedBlock = allBlocks[blockIndex + 1]; - const newElementId = duplicatedBlock?.elements[0]?.id; - - if (newElementId) { - setActiveQuestionId(newElementId); - internalQuestionIdMap[newElementId] = createId(); - } + setActiveQuestionId(newElementId); + internalQuestionIdMap[newElementId] = createId(); setLocalSurvey(result.data); toast.success(t("environments.surveys.edit.question_duplicated")); }; - const addQuestion = (question: TSurveyQuestion, index?: number) => { + const addQuestion = (question: TSurveyElement, index?: number) => { const languageSymbols = extractLanguageCodes(localSurvey.languages); const updatedQuestion = addMultiLanguageLabels(question, languageSymbols); - const blockName = getBlockName(index ?? questions.length); + const blockName = getBlockName(index ?? localSurvey.blocks.length); const newBlock = { name: blockName, elements: [{ ...updatedQuestion, isDraft: true }], @@ -543,6 +555,31 @@ export const QuestionsView = ({ internalQuestionIdMap[question.id] = createId(); }; + const _addElementToBlock = (question: TSurveyElement, blockId: string, afterElementIdx: number) => { + const languageSymbols = extractLanguageCodes(localSurvey.languages); + const updatedQuestion = addMultiLanguageLabels(question, languageSymbols); + + const targetIndex = afterElementIdx + 1; + const result = addElementToBlock( + localSurvey, + blockId, + { + ...updatedQuestion, + isDraft: true, + }, + targetIndex + ); + + if (!result.ok) { + toast.error(result.error.message); + return; + } + + setLocalSurvey(result.data); + setActiveQuestionId(updatedQuestion.id); + internalQuestionIdMap[updatedQuestion.id] = createId(); + }; + const addEndingCard = (index: number) => { const updatedSurvey = structuredClone(localSurvey); const newEndingCard = getDefaultEndingCard(localSurvey.languages, t); @@ -557,11 +594,87 @@ export const QuestionsView = ({ const question = questions[questionIndex]; if (!question) return; - const { blockId } = findElementLocation(localSurvey, question.id); - if (!blockId) return; + const { blockId, blockIndex } = findElementLocation(localSurvey, question.id); + if (!blockId || blockIndex === -1) return; + const block = localSurvey.blocks[blockIndex]; + const elementIndex = block.elements.findIndex((el) => el.id === question.id); + + // If block has multiple elements, move element within the block + if (block.elements.length > 1) { + // Check if we can move in the desired direction within the block + if ((up && elementIndex > 0) || (!up && elementIndex < block.elements.length - 1)) { + const direction = up ? "up" : "down"; + const result = moveElementInBlock(localSurvey, blockId, question.id, direction); + + if (!result.ok) { + toast.error(result.error.message); + return; + } + + setLocalSurvey(result.data); + return; + } + // If we can't move within block, fall through to move the entire block + } + + // Move the entire block const direction = up ? "up" : "down"; - const result = moveBlock(localSurvey, blockId, direction); + const result = moveBlockHelper(localSurvey, blockId, direction); + + if (!result.ok) { + toast.error(result.error.message); + return; + } + + setLocalSurvey(result.data); + }; + + // Block-level operations + const duplicateBlock = (blockId: string) => { + const result = duplicateBlockHelper(localSurvey, blockId); + + if (!result.ok) { + toast.error(result.error.message); + return; + } + + // Find the duplicated block and set the first element as active + const blockIndex = localSurvey.blocks.findIndex((b) => b.id === blockId); + if (blockIndex !== -1) { + const duplicatedBlock = result.data.blocks[blockIndex + 1]; + if (duplicatedBlock?.elements[0]) { + setActiveQuestionId(duplicatedBlock.elements[0].id); + internalQuestionIdMap[duplicatedBlock.elements[0].id] = createId(); + } + } + + setLocalSurvey(result.data); + toast.success(t("environments.surveys.edit.block_duplicated")); + }; + + const deleteBlockById = (blockId: string) => { + const result = deleteBlock(localSurvey, blockId); + + if (!result.ok) { + toast.error(result.error.message); + return; + } + + // Set active question to the first element of the first remaining block or ending card + const newBlocks = result.data.blocks ?? []; + if (newBlocks.length > 0 && newBlocks[0].elements.length > 0) { + setActiveQuestionId(newBlocks[0].elements[0].id); + } else if (result.data.endings[0]) { + setActiveQuestionId(result.data.endings[0].id); + } + + setLocalSurvey(result.data); + toast.success(t("environments.surveys.edit.block_deleted")); + }; + + const moveBlockById = (blockId: string, direction: "up" | "down") => { + const result = moveBlockHelper(localSurvey, blockId, direction); if (!result.ok) { toast.error(result.error.message); @@ -575,13 +688,12 @@ export const QuestionsView = ({ useEffect(() => { if (!invalidQuestions) return; let updatedInvalidQuestions: string[] = invalidQuestions; - // Validate each question - questions.forEach((question, index) => { - updatedInvalidQuestions = validateSurveyQuestionsInBatch( - question as unknown as TSurveyQuestion, + // Validate each element + questions.forEach((element) => { + updatedInvalidQuestions = validateSurveyElementsInBatch( + element, updatedInvalidQuestions, - surveyLanguages, - index === 0 + surveyLanguages ); }); @@ -669,8 +781,9 @@ export const QuestionsView = ({ sensors={sensors} onDragEnd={onQuestionCardDragEnd} collisionDetection={closestCorners}> - setIsCautionDialogOpen(true)} isStorageConfigured={isStorageConfigured} isExternalUrlsAllowed={isExternalUrlsAllowed} + duplicateBlock={duplicateBlock} + deleteBlock={deleteBlockById} + moveBlock={moveBlockById} + addElementToBlock={_addElementToBlock} /> 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 f79c8c5396..a6c8e63b2a 100644 --- a/apps/web/modules/survey/editor/components/ranking-question-form.tsx +++ b/apps/web/modules/survey/editor/components/ranking-question-form.tsx @@ -8,7 +8,8 @@ import { PlusIcon } from "lucide-react"; import { type JSX, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { TI18nString } from "@formbricks/types/i18n"; -import { TSurvey, TSurveyRankingQuestion } from "@formbricks/types/surveys/types"; +import { TSurveyRankingElement } from "@formbricks/types/surveys/elements"; +import { TSurvey } 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"; @@ -19,9 +20,9 @@ import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-sele interface RankingQuestionFormProps { localSurvey: TSurvey; - question: TSurveyRankingQuestion; + question: TSurveyRankingElement; questionIdx: number; - updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; + updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; selectedLanguageCode: string; setSelectedLanguageCode: (language: string) => void; isInvalid: boolean; diff --git a/apps/web/modules/survey/editor/components/rating-question-form.tsx b/apps/web/modules/survey/editor/components/rating-question-form.tsx index 15695cdde9..ce1161da54 100644 --- a/apps/web/modules/survey/editor/components/rating-question-form.tsx +++ b/apps/web/modules/survey/editor/components/rating-question-form.tsx @@ -3,7 +3,9 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; import { HashIcon, PlusIcon, SmileIcon, StarIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { TSurvey, TSurveyRatingQuestion } from "@formbricks/types/surveys/types"; +import { TI18nString } from "@formbricks/types/i18n"; +import { TSurveyRatingElement } from "@formbricks/types/surveys/elements"; +import { TSurvey } 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"; @@ -14,9 +16,9 @@ import { Label } from "@/modules/ui/components/label"; interface RatingQuestionFormProps { localSurvey: TSurvey; - question: TSurveyRatingQuestion; + question: TSurveyRatingElement; questionIdx: number; - updateQuestion: (questionIdx: number, updatedAttributes: any) => void; + updateQuestion: (questionIdx: number, updatedAttributes: Partial) => void; lastQuestion: boolean; selectedLanguageCode: string; setSelectedLanguageCode: (language: string) => void; @@ -24,6 +26,7 @@ interface RatingQuestionFormProps { locale: TUserLocale; isStorageConfigured: boolean; isExternalUrlsAllowed?: boolean; + buttonLabel?: TI18nString; } export const RatingQuestionForm = ({ @@ -37,6 +40,7 @@ export const RatingQuestionForm = ({ locale, isStorageConfigured = true, isExternalUrlsAllowed, + buttonLabel, }: RatingQuestionFormProps) => { const { t } = useTranslation(); const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages); @@ -115,7 +119,7 @@ export const RatingQuestionForm = ({ updateQuestion(questionIdx, { scale: option.value, isColorCodingEnabled: false }); return; } - updateQuestion(questionIdx, { scale: option.value }); + updateQuestion(questionIdx, { scale: option.value as "number" | "smiley" | "star" }); }} />
@@ -134,7 +138,9 @@ export const RatingQuestionForm = ({ ]} /* disabled={survey.status !== "draft"} */ defaultValue={question.range || 5} - onSelect={(option) => updateQuestion(questionIdx, { range: option.value })} + onSelect={(option) => + updateQuestion(questionIdx, { range: option.value as TSurveyRatingElement["range"] }) + } />
@@ -180,7 +186,7 @@ export const RatingQuestionForm = ({
{ - const translations: Record = { - "environments.surveys.edit.untitled_block": "Untitled Block", - }; - return translations[key] || key; -}) as TFunction; +vi.mock("@paralleldrive/cuid2", () => ({ + createId: vi.fn(() => "test-cuid-" + Math.random().toString(36).substring(7)), +})); -// Helper to create a mock survey -const createMockSurvey = (): TSurvey => ({ - id: "test-survey-id", - name: "Test Survey", - type: "link", - environmentId: "test-env-id", - createdBy: null, - status: "draft", - welcomeCard: { enabled: false, timeToFinish: false, showResponseCount: false }, - questions: [], - endings: [], - hiddenFields: { enabled: false }, - variables: [], - displayOption: "displayOnce", - recontactDays: null, - displayLimit: null, - autoClose: null, - delay: 0, - displayPercentage: null, - autoComplete: null, - isVerifyEmailEnabled: false, - isSingleResponsePerEmailEnabled: false, - projectOverwrites: null, - styling: null, - surveyClosedMessage: null, - singleUse: null, - pin: null, - languages: [], - showLanguageSwitch: null, - segment: null, - triggers: [], +const mockT = ((key: string) => key) as never; + +const createMockElement = (id: string): TSurveyElement => ({ + id, + type: TSurveyElementTypeEnum.OpenText, + headline: { default: "Test Question" }, + required: false, + inputType: "text", + longAnswer: true, + charLimit: { enabled: false }, +}); + +const createMockBlock = (id: string, name: string, elements: TSurveyElement[] = []): TSurveyBlock => ({ + id, + name, + elements, +}); + +const createMockSurvey = (blocks: TSurveyBlock[] = []): TSurvey => ({ + id: "survey-1", createdAt: new Date(), updatedAt: new Date(), - blocks: [ - { - id: "block-1", - name: "Block 1", - elements: [ - { - id: "elem-1", - type: TSurveyElementTypeEnum.OpenText, - headline: { default: "Question 1" }, - required: true, - inputType: "text", - } as any, - { - id: "elem-2", - type: TSurveyElementTypeEnum.OpenText, - headline: { default: "Question 2" }, - required: false, - inputType: "email", - } as any, - ], - }, - { - id: "block-2", - name: "Block 2", - elements: [ - { - id: "elem-3", - type: TSurveyElementTypeEnum.Rating, - headline: { default: "Rate us" }, - required: true, - scale: "star", - range: 5, - } as any, - ], - }, - ], + name: "Test Survey", + type: "link", + environmentId: "env-1", + createdBy: null, + status: "draft", + displayOption: "respondMultiple", + autoClose: null, + triggers: [], + recontactDays: null, + displayLimit: null, + welcomeCard: { + enabled: false, + headline: { default: "Welcome" }, + timeToFinish: false, + showResponseCount: false, + }, + questions: [], + blocks, + endings: [], + hiddenFields: { enabled: false, fieldIds: [] }, + variables: [], + styling: null, + segment: null, + languages: [], + displayPercentage: null, + isVerifyEmailEnabled: false, + isSingleResponsePerEmailEnabled: false, + singleUse: null, + pin: null, + projectOverwrites: null, + surveyClosedMessage: null, followUps: [], + delay: 0, + autoComplete: null, + showLanguageSwitch: null, recaptcha: null, isBackButtonHidden: false, metadata: {}, }); -describe("Block Utility Functions", () => { - describe("isElementIdUnique", () => { - test("should return true for unique element ID", () => { - const survey = createMockSurvey(); - const isUnique = isElementIdUnique("new-elem", survey.blocks); - expect(isUnique).toBe(true); - }); +describe("isElementIdUnique", () => { + test("should return true for a unique element ID", () => { + const blocks = [ + createMockBlock("block-1", "Block 1", [createMockElement("q1")]), + createMockBlock("block-2", "Block 2", [createMockElement("q2")]), + ]; - test("should return false for duplicate element ID", () => { - const survey = createMockSurvey(); - const isUnique = isElementIdUnique("elem-1", survey.blocks); - expect(isUnique).toBe(false); - }); + expect(isElementIdUnique("q3", blocks)).toBe(true); + }); + + test("should return false for a duplicate element ID", () => { + const blocks = [ + createMockBlock("block-1", "Block 1", [createMockElement("q1")]), + createMockBlock("block-2", "Block 2", [createMockElement("q2")]), + ]; + + expect(isElementIdUnique("q1", blocks)).toBe(false); + expect(isElementIdUnique("q2", blocks)).toBe(false); + }); + + test("should return true for empty blocks", () => { + expect(isElementIdUnique("q1", [])).toBe(true); }); }); -describe("Block Operations", () => { - describe("addBlock", () => { - test("should add a block to the end by default", () => { - const survey = createMockSurvey(); - const result = addBlock(mockT, survey, { name: "Block 3", elements: [] }); +describe("findElementLocation", () => { + test("should find element location correctly", () => { + const element1 = createMockElement("q1"); + const element2 = createMockElement("q2"); + const block1 = createMockBlock("block-1", "Block 1", [element1]); + const block2 = createMockBlock("block-2", "Block 2", [element2]); + const survey = createMockSurvey([block1, block2]); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks.length).toBe(3); - expect(result.data.blocks[2].name).toBe("Block 3"); - expect(result.data.blocks[2].id).toBeTruthy(); - } - }); + const result = findElementLocation(survey, "q2"); - test("should add a block at specific index", () => { - const survey = createMockSurvey(); - const result = addBlock(mockT, survey, { name: "Block 1.5", elements: [] }, 1); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks.length).toBe(3); - expect(result.data.blocks[1].name).toBe("Block 1.5"); - } - }); - - test("should return error for invalid index", () => { - const survey = createMockSurvey(); - const result = addBlock(mockT, survey, { name: "Block X", elements: [] }, 10); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("Invalid index"); - } - }); - - test("should use default name if not provided", () => { - const survey = createMockSurvey(); - const result = addBlock(mockT, survey, { elements: [] }); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks[2].name).toBe("Untitled Block"); - } - }); + expect(result.blockId).toBe("block-2"); + expect(result.blockIndex).toBe(1); + expect(result.elementIndex).toBe(0); + expect(result.block).toEqual(block2); }); - describe("updateBlock", () => { - test("should update block attributes", () => { - const survey = createMockSurvey(); - const result = updateBlock(survey, "block-1", { name: "Updated Block 1" }); + test("should return null values when element is not found", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1", [createMockElement("q1")])]); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks[0].name).toBe("Updated Block 1"); - } - }); + const result = findElementLocation(survey, "nonexistent"); - test("should return error for non-existent block", () => { - const survey = createMockSurvey(); - const result = updateBlock(survey, "non-existent", { name: "Updated" }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("not found"); - } - }); + expect(result.blockId).toBe(null); + expect(result.blockIndex).toBe(-1); + expect(result.elementIndex).toBe(-1); + expect(result.block).toBe(null); }); - describe("deleteBlock", () => { - test("should delete a block", () => { - const survey = createMockSurvey(); - const result = deleteBlock(survey, "block-1"); + test("should find element in the middle of multiple elements", () => { + const elements = [createMockElement("q1"), createMockElement("q2"), createMockElement("q3")]; + const block = createMockBlock("block-1", "Block 1", elements); + const survey = createMockSurvey([block]); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks.length).toBe(1); - expect(result.data.blocks[0].id).toBe("block-2"); - } - }); + const result = findElementLocation(survey, "q2"); - test("should return error for non-existent block", () => { - const survey = createMockSurvey(); - const result = deleteBlock(survey, "non-existent"); + expect(result.blockId).toBe("block-1"); + expect(result.blockIndex).toBe(0); + expect(result.elementIndex).toBe(1); + }); +}); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("not found"); - } - }); +describe("addBlock", () => { + test("should add a block to empty survey", () => { + const survey = createMockSurvey([]); + const result = addBlock(mockT, survey, { name: "New Block" }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks).toHaveLength(1); + expect(result.data.blocks[0].name).toBe("New Block"); + expect(result.data.blocks[0].elements).toEqual([]); + } }); - describe("duplicateBlock", () => { - test("should duplicate a block with new IDs", () => { - const survey = createMockSurvey(); - const result = duplicateBlock(survey, "block-1"); + test("should append block to end by default", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1")]); + const result = addBlock(mockT, survey, { name: "Block 2" }); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks.length).toBe(3); - const duplicated = result.data.blocks[1]; - expect(duplicated.name).toBe("Block 1 (copy)"); - expect(duplicated.id).not.toBe("block-1"); - expect(duplicated.elements.length).toBe(2); - // Element IDs should be different - expect(duplicated.elements[0].id).not.toBe("elem-1"); - } + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks).toHaveLength(2); + expect(result.data.blocks[1].name).toBe("Block 2"); + } + }); + + test("should insert block at specific index", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1"), + createMockBlock("block-2", "Block 2"), + ]); + const result = addBlock(mockT, survey, { name: "Block 1.5" }, 1); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks).toHaveLength(3); + expect(result.data.blocks[1].name).toBe("Block 1.5"); + expect(result.data.blocks[0].name).toBe("Block 1"); + expect(result.data.blocks[2].name).toBe("Block 2"); + } + }); + + test("should use default name if not provided", () => { + const survey = createMockSurvey([]); + const result = addBlock(mockT, survey, {}); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].name).toBe("environments.surveys.edit.untitled_block"); + } + }); + + test("should return error for invalid index", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1")]); + const result = addBlock(mockT, survey, { name: "Invalid" }, 10); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain("Invalid index"); + } + }); + + test("should return error for negative index", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1")]); + const result = addBlock(mockT, survey, { name: "Invalid" }, -1); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain("Invalid index"); + } + }); +}); + +describe("updateBlock", () => { + test("should update block name", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Old Name")]); + const result = updateBlock(survey, "block-1", { name: "New Name" }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].name).toBe("New Name"); + } + }); + + test("should update multiple block attributes", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1")]); + const result = updateBlock(survey, "block-1", { + name: "Updated", + buttonLabel: { default: "Next" }, }); - test("should clear logic on duplicated block", () => { - const survey = createMockSurvey(); - survey.blocks[0].logic = [ - { - id: "logic-1", - conditions: { connector: "and", conditions: [] }, - actions: [], + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].name).toBe("Updated"); + expect(result.data.blocks[0].buttonLabel).toEqual({ default: "Next" }); + } + }); + + test("should return error when block not found", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1")]); + const result = updateBlock(survey, "nonexistent", { name: "Updated" }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Block with ID "nonexistent" not found'); + } + }); + + test("should return error when trying to update id", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1")]); + const result = updateBlock(survey, "block-1", { id: "new-id" } as any); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe("Block ID cannot be updated"); + } + }); +}); + +describe("deleteBlock", () => { + test("should delete a block", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1"), + createMockBlock("block-2", "Block 2"), + ]); + const result = deleteBlock(survey, "block-1"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks).toHaveLength(1); + expect(result.data.blocks[0].id).toBe("block-2"); + } + }); + + test("should return error when block not found", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1")]); + const result = deleteBlock(survey, "nonexistent"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Block with ID "nonexistent" not found'); + } + }); + + test("should handle deleting from empty survey", () => { + const survey = createMockSurvey([]); + const result = deleteBlock(survey, "block-1"); + + expect(result.ok).toBe(false); + }); +}); + +describe("duplicateBlock", () => { + test("should duplicate a block with new IDs", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1", [createMockElement("q1"), createMockElement("q2")]), + ]); + const result = duplicateBlock(survey, "block-1"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks).toHaveLength(2); + expect(result.data.blocks[1].name).toBe("Block 1 (copy)"); + expect(result.data.blocks[1].id).not.toBe("block-1"); + expect(result.data.blocks[1].elements[0].id).not.toBe("q1"); + expect(result.data.blocks[1].elements[1].id).not.toBe("q2"); + expect(result.data.blocks[1].elements[0].isDraft).toBe(true); + expect(result.data.blocks[1].elements[1].isDraft).toBe(true); + } + }); + + test("should clear logic when duplicating", () => { + const blockWithLogic = createMockBlock("block-1", "Block 1", [createMockElement("q1")]); + blockWithLogic.logic = [ + { + id: "logic-1", + conditions: { + id: "cond-1", + connector: "and", + conditions: [], }, - ] as any; + actions: [], + }, + ]; + blockWithLogic.logicFallback = "block-2"; - const result = duplicateBlock(survey, "block-1"); + const survey = createMockSurvey([blockWithLogic]); + const result = duplicateBlock(survey, "block-1"); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks[1].logic).toBeUndefined(); - } - }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[1].logic).toBeUndefined(); + expect(result.data.blocks[1].logicFallback).toBeUndefined(); + } }); - describe("moveBlock", () => { - test("should move block down", () => { - const survey = createMockSurvey(); - const result = moveBlock(survey, "block-1", "down"); + test("should insert duplicate after original block", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1"), + createMockBlock("block-2", "Block 2"), + createMockBlock("block-3", "Block 3"), + ]); + const result = duplicateBlock(survey, "block-2"); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks[0].id).toBe("block-2"); - expect(result.data.blocks[1].id).toBe("block-1"); - } - }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks).toHaveLength(4); + expect(result.data.blocks[2].name).toBe("Block 2 (copy)"); + expect(result.data.blocks[1].id).toBe("block-2"); + expect(result.data.blocks[3].id).toBe("block-3"); + } + }); - test("should move block up", () => { - const survey = createMockSurvey(); - const result = moveBlock(survey, "block-2", "up"); + test("should return error when block not found", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1")]); + const result = duplicateBlock(survey, "nonexistent"); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks[0].id).toBe("block-2"); - expect(result.data.blocks[1].id).toBe("block-1"); - } - }); - - test("should return unchanged survey when moving first block up", () => { - const survey = createMockSurvey(); - const result = moveBlock(survey, "block-1", "up"); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks[0].id).toBe("block-1"); - } - }); - - test("should return unchanged survey when moving last block down", () => { - const survey = createMockSurvey(); - const result = moveBlock(survey, "block-2", "down"); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks[1].id).toBe("block-2"); - } - }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Block with ID "nonexistent" not found'); + } }); }); -describe("Element Operations", () => { - describe("addElementToBlock", () => { - test("should add element to block", () => { - const survey = createMockSurvey(); - const newElement = { - id: "elem-new", - type: TSurveyElementTypeEnum.OpenText, - headline: { default: "New Question" }, - required: false, - inputType: "text", - } as any; +describe("moveBlock", () => { + test("should move block up", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1"), + createMockBlock("block-2", "Block 2"), + createMockBlock("block-3", "Block 3"), + ]); + const result = moveBlock(survey, "block-2", "up"); - const result = addElementToBlock(survey, "block-1", newElement); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks[0].elements.length).toBe(3); - expect(result.data.blocks[0].elements[2].id).toBe("elem-new"); - expect(result.data.blocks[0].elements[2].isDraft).toBe(true); - } - }); - - test("should return error for duplicate element ID", () => { - const survey = createMockSurvey(); - const duplicateElement = { - id: "elem-1", // Already exists - type: TSurveyElementTypeEnum.OpenText, - headline: { default: "Duplicate" }, - required: false, - inputType: "text", - } as any; - - const result = addElementToBlock(survey, "block-2", duplicateElement); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("already exists"); - } - }); - - test("should return error for duplicate element ID within same block", () => { - const survey = createMockSurvey(); - const duplicateElement = { - id: "elem-1", // Already exists in block-1 - type: TSurveyElementTypeEnum.Rating, - headline: { default: "Duplicate in same block" }, - required: false, - range: 5, - scale: "star", - } as any; - - // Try to add to the same block where elem-1 already exists - const result = addElementToBlock(survey, "block-1", duplicateElement); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("already exists"); - } - }); - - test("should return error for non-existent block", () => { - const survey = createMockSurvey(); - const element = { - id: "elem-new", - type: TSurveyElementTypeEnum.OpenText, - headline: { default: "Question" }, - required: false, - inputType: "text", - } as any; - - const result = addElementToBlock(survey, "non-existent", element); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("not found"); - } - }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].id).toBe("block-2"); + expect(result.data.blocks[1].id).toBe("block-1"); + expect(result.data.blocks[2].id).toBe("block-3"); + } }); - describe("updateElementInBlock", () => { - test("should update element attributes", () => { - const survey = createMockSurvey(); - const result = updateElementInBlock(survey, "block-1", "elem-1", { - headline: { default: "Updated Question" }, - }); + test("should move block down", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1"), + createMockBlock("block-2", "Block 2"), + createMockBlock("block-3", "Block 3"), + ]); + const result = moveBlock(survey, "block-2", "down"); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks![0].elements[0].headline.default).toBe("Updated Question"); - } - }); - - test("should allow updating element ID to a unique ID", () => { - const survey = createMockSurvey(); - const result = updateElementInBlock(survey, "block-1", "elem-1", { - id: "elem-new-id", - }); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks![0].elements[0].id).toBe("elem-new-id"); - } - }); - - test("should return error when updating element ID to duplicate within same block", () => { - const survey = createMockSurvey(); - const result = updateElementInBlock(survey, "block-1", "elem-1", { - id: "elem-2", // elem-2 already exists in block-1 - }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("already exists"); - } - }); - - test("should return error when updating element ID to duplicate in another block", () => { - const survey = createMockSurvey(); - const result = updateElementInBlock(survey, "block-1", "elem-1", { - id: "elem-3", // elem-3 exists in block-2 - }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("already exists"); - } - }); - - test("should return error for non-existent element", () => { - const survey = createMockSurvey(); - const result = updateElementInBlock(survey, "block-1", "non-existent", { - headline: { default: "Updated" }, - }); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("not found"); - } - }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].id).toBe("block-1"); + expect(result.data.blocks[1].id).toBe("block-3"); + expect(result.data.blocks[2].id).toBe("block-2"); + } }); - describe("deleteElementFromBlock", () => { - test("should delete element from block", () => { - const survey = createMockSurvey(); - const result = deleteElementFromBlock(survey, "block-1", "elem-2"); + test("should not move first block up", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1"), + createMockBlock("block-2", "Block 2"), + ]); + const result = moveBlock(survey, "block-1", "up"); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks[0].elements.length).toBe(1); - expect(result.data.blocks[0].elements[0].id).toBe("elem-1"); - } - }); - - test("should return error for non-existent element", () => { - const survey = createMockSurvey(); - const result = deleteElementFromBlock(survey, "block-1", "non-existent"); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("not found"); - } - }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].id).toBe("block-1"); + expect(result.data.blocks[1].id).toBe("block-2"); + } }); - describe("duplicateElementInBlock", () => { - test("should duplicate element with new ID", () => { - const survey = createMockSurvey(); - const result = duplicateElementInBlock(survey, "block-1", "elem-1"); + test("should not move last block down", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1"), + createMockBlock("block-2", "Block 2"), + ]); + const result = moveBlock(survey, "block-2", "down"); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks[0].elements.length).toBe(3); - const duplicated = result.data.blocks![0].elements[1]; - expect(duplicated.id).not.toBe("elem-1"); - expect(duplicated.isDraft).toBe(true); - expect(duplicated.headline.default).toBe("Question 1"); - } - }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].id).toBe("block-1"); + expect(result.data.blocks[1].id).toBe("block-2"); + } + }); - test("should return error for non-existent element", () => { - const survey = createMockSurvey(); - const result = duplicateElementInBlock(survey, "block-1", "non-existent"); + test("should return error when block not found", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1")]); + const result = moveBlock(survey, "nonexistent", "up"); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error.message).toContain("not found"); - } - }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Block with ID "nonexistent" not found'); + } + }); +}); + +describe("addElementToBlock", () => { + test("should add element to block", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1")]); + const element = createMockElement("q1"); + const result = addElementToBlock(survey, "block-1", element); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].elements).toHaveLength(1); + expect(result.data.blocks[0].elements[0].id).toBe("q1"); + expect(result.data.blocks[0].elements[0].isDraft).toBe(true); + } + }); + + test("should append element to end by default", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1", [createMockElement("q1"), createMockElement("q2")]), + ]); + const element = createMockElement("q3"); + const result = addElementToBlock(survey, "block-1", element); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].elements).toHaveLength(3); + expect(result.data.blocks[0].elements[2].id).toBe("q3"); + } + }); + + test("should insert element at specific index", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1", [createMockElement("q1"), createMockElement("q2")]), + ]); + const element = createMockElement("q1.5"); + const result = addElementToBlock(survey, "block-1", element, 1); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].elements).toHaveLength(3); + expect(result.data.blocks[0].elements[1].id).toBe("q1.5"); + expect(result.data.blocks[0].elements[0].id).toBe("q1"); + expect(result.data.blocks[0].elements[2].id).toBe("q2"); + } + }); + + test("should return error for duplicate element ID", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1", [createMockElement("q1")]), + createMockBlock("block-2", "Block 2", [createMockElement("q2")]), + ]); + const element = createMockElement("q1"); + const result = addElementToBlock(survey, "block-2", element); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Element ID "q1" already exists'); + } + }); + + test("should return error when block not found", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1")]); + const element = createMockElement("q1"); + const result = addElementToBlock(survey, "nonexistent", element); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Block with ID "nonexistent" not found'); + } + }); + + test("should return error for invalid index", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1", [createMockElement("q1")])]); + const element = createMockElement("q2"); + const result = addElementToBlock(survey, "block-1", element, 10); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain("Invalid index"); + } + }); +}); + +describe("updateElementInBlock", () => { + test("should update element headline", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1", [createMockElement("q1")])]); + const result = updateElementInBlock(survey, "block-1", "q1", { + headline: { default: "Updated Question" }, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].elements[0].headline).toEqual({ default: "Updated Question" }); + } + }); + + test("should update element ID if new ID is unique", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1", [createMockElement("q1")])]); + const result = updateElementInBlock(survey, "block-1", "q1", { id: "q2" }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].elements[0].id).toBe("q2"); + } + }); + + test("should return error when updating to duplicate element ID", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1", [createMockElement("q1"), createMockElement("q2")]), + ]); + const result = updateElementInBlock(survey, "block-1", "q1", { id: "q2" }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Element ID "q2" already exists'); + } + }); + + test("should return error when block not found", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1", [createMockElement("q1")])]); + const result = updateElementInBlock(survey, "nonexistent", "q1", { + headline: { default: "Updated" }, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Block with ID "nonexistent" not found'); + } + }); + + test("should return error when element not found", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1", [createMockElement("q1")])]); + const result = updateElementInBlock(survey, "block-1", "nonexistent", { + headline: { default: "Updated" }, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Element with ID "nonexistent" not found'); + } + }); +}); + +describe("deleteElementFromBlock", () => { + test("should delete element from block", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1", [createMockElement("q1"), createMockElement("q2")]), + ]); + const result = deleteElementFromBlock(survey, "block-1", "q1"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].elements).toHaveLength(1); + expect(result.data.blocks[0].elements[0].id).toBe("q2"); + } + }); + + test("should return error when block not found", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1", [createMockElement("q1")])]); + const result = deleteElementFromBlock(survey, "nonexistent", "q1"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Block with ID "nonexistent" not found'); + } + }); + + test("should return error when element not found", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1", [createMockElement("q1")])]); + const result = deleteElementFromBlock(survey, "block-1", "nonexistent"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Element with ID "nonexistent" not found'); + } + }); +}); + +describe("duplicateElementInBlock", () => { + test("should duplicate element with new ID", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1", [createMockElement("q1")])]); + const result = duplicateElementInBlock(survey, "block-1", "q1"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].elements).toHaveLength(2); + expect(result.data.blocks[0].elements[1].id).not.toBe("q1"); + expect(result.data.blocks[0].elements[1].isDraft).toBe(true); + expect(result.data.blocks[0].elements[1].headline).toEqual({ default: "Test Question" }); + } + }); + + test("should insert duplicate after original element", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1", [ + createMockElement("q1"), + createMockElement("q2"), + createMockElement("q3"), + ]), + ]); + const result = duplicateElementInBlock(survey, "block-1", "q2"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].elements).toHaveLength(4); + expect(result.data.blocks[0].elements[1].id).toBe("q2"); + expect(result.data.blocks[0].elements[2].id).not.toBe("q2"); + expect(result.data.blocks[0].elements[3].id).toBe("q3"); + } + }); + + test("should return error when block not found", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1", [createMockElement("q1")])]); + const result = duplicateElementInBlock(survey, "nonexistent", "q1"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Block with ID "nonexistent" not found'); + } + }); + + test("should return error when element not found", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1", [createMockElement("q1")])]); + const result = duplicateElementInBlock(survey, "block-1", "nonexistent"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Element with ID "nonexistent" not found'); + } + }); +}); + +describe("moveElementInBlock", () => { + test("should move element up", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1", [ + createMockElement("q1"), + createMockElement("q2"), + createMockElement("q3"), + ]), + ]); + const result = moveElementInBlock(survey, "block-1", "q2", "up"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].elements[0].id).toBe("q2"); + expect(result.data.blocks[0].elements[1].id).toBe("q1"); + expect(result.data.blocks[0].elements[2].id).toBe("q3"); + } + }); + + test("should move element down", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1", [ + createMockElement("q1"), + createMockElement("q2"), + createMockElement("q3"), + ]), + ]); + const result = moveElementInBlock(survey, "block-1", "q2", "down"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].elements[0].id).toBe("q1"); + expect(result.data.blocks[0].elements[1].id).toBe("q3"); + expect(result.data.blocks[0].elements[2].id).toBe("q2"); + } + }); + + test("should not move first element up", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1", [createMockElement("q1"), createMockElement("q2")]), + ]); + const result = moveElementInBlock(survey, "block-1", "q1", "up"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].elements[0].id).toBe("q1"); + expect(result.data.blocks[0].elements[1].id).toBe("q2"); + } + }); + + test("should not move last element down", () => { + const survey = createMockSurvey([ + createMockBlock("block-1", "Block 1", [createMockElement("q1"), createMockElement("q2")]), + ]); + const result = moveElementInBlock(survey, "block-1", "q2", "down"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].elements[0].id).toBe("q1"); + expect(result.data.blocks[0].elements[1].id).toBe("q2"); + } + }); + + test("should return error when block not found", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1", [createMockElement("q1")])]); + const result = moveElementInBlock(survey, "nonexistent", "q1", "up"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Block with ID "nonexistent" not found'); + } + }); + + test("should return error when element not found", () => { + const survey = createMockSurvey([createMockBlock("block-1", "Block 1", [createMockElement("q1")])]); + const result = moveElementInBlock(survey, "block-1", "nonexistent", "up"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain('Element with ID "nonexistent" not found'); + } }); }); diff --git a/apps/web/modules/survey/editor/lib/blocks.ts b/apps/web/modules/survey/editor/lib/blocks.ts index bcb9dc2bc2..48479baf53 100644 --- a/apps/web/modules/survey/editor/lib/blocks.ts +++ b/apps/web/modules/survey/editor/lib/blocks.ts @@ -411,3 +411,54 @@ export const duplicateElementInBlock = ( blocks, }); }; + +/** + * Moves an element up or down within a block + * @param survey - The survey containing the block + * @param blockId - The CUID of the block containing the element + * @param elementId - The ID of the element to move + * @param direction - Direction to move ("up" or "down") + * @returns Result with updated survey (or unchanged if at boundary) or Error + */ +export const moveElementInBlock = ( + survey: TSurvey, + blockId: string, + elementId: string, + direction: "up" | "down" +): Result => { + const blocks = [...(survey.blocks || [])]; + const blockIndex = blocks.findIndex((b) => b.id === blockId); + + if (blockIndex === -1) { + return err(new Error(`Block with ID "${blockId}" not found`)); + } + + const block = { ...blocks[blockIndex] }; + const elements = [...block.elements]; + const elementIndex = elements.findIndex((e) => e.id === elementId); + + if (elementIndex === -1) { + return err(new Error(`Element with ID "${elementId}" not found in block "${blockId}"`)); + } + + if (direction === "up" && elementIndex === 0) { + return ok(survey); // Already at top + } + + if (direction === "down" && elementIndex === elements.length - 1) { + return ok(survey); // Already at bottom + } + + const targetIndex = direction === "up" ? elementIndex - 1 : elementIndex + 1; + + // Swap using destructuring assignment + [elements[elementIndex], elements[targetIndex]] = [elements[targetIndex], elements[elementIndex]]; + + block.elements = elements; + blocks[blockIndex] = block; + + return ok({ + ...survey, + blocks, + }); +}; diff --git a/apps/web/modules/survey/editor/lib/validation.test.ts b/apps/web/modules/survey/editor/lib/validation.test.ts index 8ea3933f6d..ceb355895a 100644 --- a/apps/web/modules/survey/editor/lib/validation.test.ts +++ b/apps/web/modules/survey/editor/lib/validation.test.ts @@ -3,14 +3,16 @@ 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 { + TSurveyConsentElement, + TSurveyElementTypeEnum, + TSurveyMultipleChoiceElement, + TSurveyOpenTextElement, +} from "@formbricks/types/surveys/elements"; import { TSurvey, - TSurveyConsentQuestion, TSurveyEndScreenCard, TSurveyLanguage, - TSurveyMultipleChoiceQuestion, - TSurveyOpenTextQuestion, - TSurveyQuestionTypeEnum, TSurveyRedirectUrlCard, TSurveyWelcomeCard, } from "@formbricks/types/surveys/types"; @@ -285,22 +287,20 @@ describe("validation.isEndingCardValid", () => { // }); }); -describe("validation.validateQuestion", () => { - const baseQuestionFields = { - id: "question1", +describe("validation.validateElement", () => { + const baseElementFields = { + id: "element1", required: false, - logic: [], }; - // Test OpenText Question - describe("OpenText Question", () => { - const openTextQuestionBase: TSurveyOpenTextQuestion = { - ...baseQuestionFields, - type: TSurveyQuestionTypeEnum.OpenText, + // Test OpenText Element + describe("OpenText Element", () => { + const openTextElementBase: TSurveyOpenTextElement = { + ...baseElementFields, + type: TSurveyElementTypeEnum.OpenText, headline: { default: "Open Text", en: "Open Text", de: "Offener Text" }, subheader: { default: "Enter here", en: "Enter here", de: "Hier eingeben" }, placeholder: { default: "Your answer...", en: "Your answer...", de: "Deine Antwort..." }, - longAnswer: false, inputType: "text", charLimit: { enabled: true, @@ -309,39 +309,39 @@ describe("validation.validateQuestion", () => { }, }; - test("should return true for a valid OpenText question", () => { - expect(validation.validateQuestion(openTextQuestionBase, surveyLanguagesEnabled, false)).toBe(true); + test("should return true for a valid OpenText element", () => { + expect(validation.validateElement(openTextElementBase, surveyLanguagesEnabled)).toBe(true); }); test("should return false if headline is invalid", () => { - const q = { ...openTextQuestionBase, headline: { default: "Open Text", en: "Open Text", de: "" } }; - expect(validation.validateQuestion(q, surveyLanguagesEnabled, false)).toBe(false); + const q = { ...openTextElementBase, headline: { default: "Open Text", en: "Open Text", de: "" } }; + expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(false); }); test("should return true if placeholder is valid (default not empty, other languages valid)", () => { const q = { - ...openTextQuestionBase, + ...openTextElementBase, placeholder: { default: "Type here", en: "Type here", de: "Tippe hier" }, }; - expect(validation.validateQuestion(q, surveyLanguagesEnabled, false)).toBe(true); + expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(true); }); test("should return false if placeholder.default is not empty but other lang is empty", () => { - const q = { ...openTextQuestionBase, placeholder: { default: "Type here", en: "Type here", de: "" } }; - expect(validation.validateQuestion(q, surveyLanguagesEnabled, false)).toBe(false); + const q = { ...openTextElementBase, placeholder: { default: "Type here", en: "Type here", de: "" } }; + expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(false); }); test("should return true if placeholder.default is empty (placeholder validation skipped)", () => { - const q = { ...openTextQuestionBase, placeholder: { default: "", en: "Type here", de: "" } }; - expect(validation.validateQuestion(q, surveyLanguagesEnabled, false)).toBe(true); + const q = { ...openTextElementBase, placeholder: { default: "", en: "Type here", de: "" } }; + expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(true); }); }); - // Test MultipleChoiceSingle Question - describe("MultipleChoiceSingle Question", () => { - const mcSingleQuestionBase: TSurveyMultipleChoiceQuestion = { - ...baseQuestionFields, - type: TSurveyQuestionTypeEnum.MultipleChoiceSingle, + // Test MultipleChoiceSingle Element + describe("MultipleChoiceSingle Element", () => { + const mcSingleElementBase: TSurveyMultipleChoiceElement = { + ...baseElementFields, + type: TSurveyElementTypeEnum.MultipleChoiceSingle, headline: { default: "Single Choice", en: "Single Choice", de: "Einzelauswahl" }, choices: [ { id: "c1", label: { default: "Option 1", en: "Option 1", de: "Option 1" } }, @@ -349,47 +349,47 @@ describe("validation.validateQuestion", () => { ], }; - test("should return true for a valid MultipleChoiceSingle question", () => { - expect(validation.validateQuestion(mcSingleQuestionBase, surveyLanguagesEnabled, false)).toBe(true); + test("should return true for a valid MultipleChoiceSingle element", () => { + expect(validation.validateElement(mcSingleElementBase, surveyLanguagesEnabled)).toBe(true); }); test("should return false if a choice label is invalid", () => { const q = { - ...mcSingleQuestionBase, + ...mcSingleElementBase, choices: [ { id: "c1", label: { default: "Option 1", en: "Option 1", de: "Option 1" } }, { id: "c2", label: { default: "Option 2", en: "Option 2", de: "" } }, ], }; - expect(validation.validateQuestion(q, surveyLanguagesEnabled, false)).toBe(false); + expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(false); }); }); - // Test Consent Question - describe("Consent Question", () => { - const consentQuestionBase: TSurveyConsentQuestion = { - ...baseQuestionFields, - type: TSurveyQuestionTypeEnum.Consent, + // Test Consent Element + describe("Consent Element", () => { + const consentElementBase: TSurveyConsentElement = { + ...baseElementFields, + type: TSurveyElementTypeEnum.Consent, headline: { default: "Consent", en: "Consent", de: "Zustimmung" }, label: { default: "I agree", en: "I agree", de: "Ich stimme zu" }, subheader: { default: "Details...", en: "Details...", de: "Details..." }, }; - test("should return true for a valid Consent question", () => { - expect(validation.validateQuestion(consentQuestionBase, surveyLanguagesEnabled, false)).toBe(true); + test("should return true for a valid Consent element", () => { + expect(validation.validateElement(consentElementBase, surveyLanguagesEnabled)).toBe(true); }); test("should return false if consent label is invalid", () => { - const q = { ...consentQuestionBase, label: { default: "I agree", en: "I agree", de: "" } }; - expect(validation.validateQuestion(q, surveyLanguagesEnabled, false)).toBe(false); + const q = { ...consentElementBase, label: { default: "I agree", en: "I agree", de: "" } }; + expect(validation.validateElement(q, surveyLanguagesEnabled)).toBe(false); }); }); }); -describe("validation.validateSurveyQuestionsInBatch", () => { - const q2Valid: TSurveyOpenTextQuestion = { +describe("validation.validateSurveyElementsInBatch", () => { + const q2Valid: TSurveyOpenTextElement = { id: "q2", - type: TSurveyQuestionTypeEnum.OpenText, + type: TSurveyElementTypeEnum.OpenText, headline: { default: "Q2", en: "Q2", de: "Q2" }, inputType: "text", charLimit: { @@ -400,9 +400,9 @@ describe("validation.validateSurveyQuestionsInBatch", () => { required: false, }; - const q2Invalid: TSurveyOpenTextQuestion = { + const q2Invalid: TSurveyOpenTextElement = { id: "q2", - type: TSurveyQuestionTypeEnum.OpenText, + type: TSurveyElementTypeEnum.OpenText, headline: { default: "Q2", en: "Q2", de: "" }, inputType: "text", charLimit: { @@ -413,42 +413,39 @@ describe("validation.validateSurveyQuestionsInBatch", () => { required: false, }; - test("should return empty array if invalidQuestions is null", () => { - expect(validation.validateSurveyQuestionsInBatch(q2Valid, null, surveyLanguagesEnabled, false)).toEqual( - [] - ); + test("should return empty array if invalidElements is null", () => { + expect(validation.validateSurveyElementsInBatch(q2Valid, null, surveyLanguagesEnabled)).toEqual([]); }); - test("should add question.id if question is invalid and not already in list", () => { - const invalidQuestions = ["q1"]; + test("should add element.id if element is invalid and not already in list", () => { + const invalidElements = ["q1"]; expect( - validation.validateSurveyQuestionsInBatch(q2Invalid, invalidQuestions, surveyLanguagesEnabled, false) + validation.validateSurveyElementsInBatch(q2Invalid, invalidElements, surveyLanguagesEnabled) ).toEqual(["q1", "q2"]); }); - test("should not add question.id if question is invalid but already in list", () => { - const invalidQuestions = ["q1", "q2"]; + test("should not add element.id if element is invalid but already in list", () => { + const invalidElements = ["q1", "q2"]; expect( - validation.validateSurveyQuestionsInBatch(q2Invalid, invalidQuestions, surveyLanguagesEnabled, false) + validation.validateSurveyElementsInBatch(q2Invalid, invalidElements, surveyLanguagesEnabled) ).toEqual(["q1", "q2"]); }); - test("should remove question.id if question is valid and in list", () => { - const invalidQuestions = ["q1", "q2"]; + test("should remove element.id if element is valid and in list", () => { + const invalidElements = ["q1", "q2"]; expect( - validation.validateSurveyQuestionsInBatch(q2Valid, invalidQuestions, surveyLanguagesEnabled, false) + validation.validateSurveyElementsInBatch(q2Valid, invalidElements, surveyLanguagesEnabled) ).toEqual(["q1"]); }); - test("should not change list if question is valid and not in list", () => { - const invalidQuestions = ["q1"]; - const validateQuestionSpy = vi.spyOn(validation, "validateQuestion"); - validateQuestionSpy.mockReturnValue(true); - const result = validation.validateSurveyQuestionsInBatch( + test("should not change list if element is valid and not in list", () => { + const invalidElements = ["q1"]; + const validateElementSpy = vi.spyOn(validation, "validateElement"); + validateElementSpy.mockReturnValue(true); + const result = validation.validateSurveyElementsInBatch( q2Valid, - [...invalidQuestions], - surveyLanguagesEnabled, - false + [...invalidElements], + surveyLanguagesEnabled ); expect(result).toEqual(["q1"]); }); @@ -468,14 +465,23 @@ describe("validation.isSurveyValid", () => { type: "web", environmentId: "env1", status: "draft", - questions: [ + questions: [], + blocks: [ { - id: "q1", - type: TSurveyQuestionTypeEnum.OpenText, - headline: { default: "Q1", en: "Q1", de: "Q1" }, - required: false, - inputType: "text", - charLimit: 0, + id: "block1", + name: "Block 1", + elements: [ + { + id: "q1", + type: TSurveyElementTypeEnum.OpenText, + headline: { default: "Q1", en: "Q1", de: "Q1" }, + required: false, + inputType: "text", + charLimit: { + enabled: false, + }, + }, + ], }, ], endings: [ @@ -500,10 +506,10 @@ describe("validation.isSurveyValid", () => { expect(toast.error).not.toHaveBeenCalled(); }); - test("should return false and toast error if checkForEmptyFallBackValue returns a question", () => { + test("should return false and toast error if checkForEmptyFallBackValue returns an element", () => { vi.mocked(checkForEmptyFallBackValue).mockReturnValue({ id: "q1", - type: TSurveyQuestionTypeEnum.OpenText, + type: TSurveyElementTypeEnum.OpenText, headline: { default: "Q1", en: "Q1", de: "Q1" }, inputType: "text", charLimit: { diff --git a/apps/web/modules/survey/editor/lib/validation.ts b/apps/web/modules/survey/editor/lib/validation.ts index 58fd39f3ce..3eaf2b1331 100644 --- a/apps/web/modules/survey/editor/lib/validation.ts +++ b/apps/web/modules/survey/editor/lib/validation.ts @@ -6,18 +6,20 @@ import { TI18nString } from "@formbricks/types/i18n"; import { ZSegmentFilters } from "@formbricks/types/segment"; import { TInputFieldConfig, + TSurveyAddressElement, + TSurveyCTAElement, + TSurveyConsentElement, + TSurveyContactInfoElement, + TSurveyElement, + TSurveyMatrixElement, + TSurveyMultipleChoiceElement, + TSurveyOpenTextElement, + TSurveyPictureSelectionElement, +} from "@formbricks/types/surveys/elements"; +import { TSurvey, - TSurveyAddressQuestion, - TSurveyCTAQuestion, - TSurveyConsentQuestion, - TSurveyContactInfoQuestion, TSurveyEndScreenCard, TSurveyLanguage, - TSurveyMatrixQuestion, - TSurveyMultipleChoiceQuestion, - TSurveyOpenTextQuestion, - TSurveyPictureSelectionQuestion, - TSurveyQuestion, TSurveyRedirectUrlCard, TSurveyWelcomeCard, } from "@formbricks/types/surveys/types"; @@ -38,13 +40,13 @@ export const isLabelValidForAllLanguages = ( return languages.every((language) => label?.[language] && getTextContent(label[language]).length > 0); }; -// Validation logic for multiple choice questions +// Validation logic for multiple choice elements const handleI18nCheckForMultipleChoice = ( - question: TSurveyMultipleChoiceQuestion, + element: TSurveyMultipleChoiceElement, languages: TSurveyLanguage[] ): boolean => { const invalidLangCodes = findLanguageCodesForDuplicateLabels( - question.choices.map((choice) => choice.label), + element.choices.map((choice) => choice.label), languages ); @@ -52,21 +54,21 @@ const handleI18nCheckForMultipleChoice = ( return false; } - return question.choices.every((choice) => isLabelValidForAllLanguages(choice.label, languages)); + return element.choices.every((choice) => isLabelValidForAllLanguages(choice.label, languages)); }; const handleI18nCheckForMatrixLabels = ( - question: TSurveyMatrixQuestion, + element: TSurveyMatrixElement, languages: TSurveyLanguage[] ): boolean => { - const rowsAndColumns = [...question.rows, ...question.columns]; + const rowsAndColumns = [...element.rows, ...element.columns]; const invalidRowsLangCodes = findLanguageCodesForDuplicateLabels( - question.rows.map((row) => row.label), + element.rows.map((row) => row.label), languages ); const invalidColumnsLangCodes = findLanguageCodesForDuplicateLabels( - question.columns.map((column) => column.label), + element.columns.map((column) => column.label), languages ); @@ -78,15 +80,15 @@ const handleI18nCheckForMatrixLabels = ( }; const handleI18nCheckForContactAndAddressFields = ( - question: TSurveyContactInfoQuestion | TSurveyAddressQuestion, + element: TSurveyContactInfoElement | TSurveyAddressElement, languages: TSurveyLanguage[] ): boolean => { let fields: TInputFieldConfig[] = []; - if (question.type === "contactInfo") { - const { firstName, lastName, phone, email, company } = question; + if (element.type === "contactInfo") { + const { firstName, lastName, phone, email, company } = element; fields = [firstName, lastName, phone, email, company]; - } else if (question.type === "address") { - const { addressLine1, addressLine2, city, state, zip, country } = question; + } else if (element.type === "address") { + const { addressLine1, addressLine2, city, state, zip, country } = element; fields = [addressLine1, addressLine2, city, state, zip, country]; } return fields.every((field) => { @@ -99,70 +101,61 @@ const handleI18nCheckForContactAndAddressFields = ( // Validation rules export const validationRules = { - openText: (question: TSurveyOpenTextQuestion, languages: TSurveyLanguage[]) => { - return question.placeholder && - getLocalizedValue(question.placeholder, "default").trim() !== "" && + openText: (element: TSurveyOpenTextElement, languages: TSurveyLanguage[]) => { + return element.placeholder && + getLocalizedValue(element.placeholder, "default").trim() !== "" && languages.length > 1 - ? isLabelValidForAllLanguages(question.placeholder, languages) + ? isLabelValidForAllLanguages(element.placeholder, languages) : true; }, - multipleChoiceMulti: (question: TSurveyMultipleChoiceQuestion, languages: TSurveyLanguage[]) => { - return handleI18nCheckForMultipleChoice(question, languages); + multipleChoiceMulti: (element: TSurveyMultipleChoiceElement, languages: TSurveyLanguage[]) => { + return handleI18nCheckForMultipleChoice(element, languages); }, - multipleChoiceSingle: (question: TSurveyMultipleChoiceQuestion, languages: TSurveyLanguage[]) => { - return handleI18nCheckForMultipleChoice(question, languages); + multipleChoiceSingle: (element: TSurveyMultipleChoiceElement, languages: TSurveyLanguage[]) => { + return handleI18nCheckForMultipleChoice(element, languages); }, - consent: (question: TSurveyConsentQuestion, languages: TSurveyLanguage[]) => { - return isLabelValidForAllLanguages(question.label, languages); + consent: (element: TSurveyConsentElement, languages: TSurveyLanguage[]) => { + return isLabelValidForAllLanguages(element.label, languages); }, - pictureSelection: (question: TSurveyPictureSelectionQuestion) => { - return question.choices.length >= 2; + pictureSelection: (element: TSurveyPictureSelectionElement) => { + return element.choices.length >= 2; }, - cta: (question: TSurveyCTAQuestion, languages: TSurveyLanguage[]) => { - return !question.required && question.dismissButtonLabel - ? isLabelValidForAllLanguages(question.dismissButtonLabel, languages) + cta: (element: TSurveyCTAElement, languages: TSurveyLanguage[]) => { + return !element.required && element.dismissButtonLabel + ? isLabelValidForAllLanguages(element.dismissButtonLabel, languages) : true; }, - matrix: (question: TSurveyMatrixQuestion, languages: TSurveyLanguage[]) => { - return handleI18nCheckForMatrixLabels(question, languages); + matrix: (element: TSurveyMatrixElement, languages: TSurveyLanguage[]) => { + return handleI18nCheckForMatrixLabels(element, languages); }, - contactInfo: (question: TSurveyContactInfoQuestion, languages: TSurveyLanguage[]) => { - return handleI18nCheckForContactAndAddressFields(question, languages); + contactInfo: (element: TSurveyContactInfoElement, languages: TSurveyLanguage[]) => { + return handleI18nCheckForContactAndAddressFields(element, languages); }, - address: (question: TSurveyAddressQuestion, languages: TSurveyLanguage[]) => { - return handleI18nCheckForContactAndAddressFields(question, languages); + address: (element: TSurveyAddressElement, languages: TSurveyLanguage[]) => { + return handleI18nCheckForContactAndAddressFields(element, languages); }, // Assuming headline is of type TI18nString - defaultValidation: (question: TSurveyQuestion, languages: TSurveyLanguage[], isFirstQuestion: boolean) => { - // headline and subheader are default for every question - const isHeadlineValid = isLabelValidForAllLanguages(question.headline, languages); + defaultValidation: (element: TSurveyElement, languages: TSurveyLanguage[]) => { + // headline and subheader are default for every element + const isHeadlineValid = isLabelValidForAllLanguages(element.headline, languages); const isSubheaderValid = - question.subheader && - getLocalizedValue(question.subheader, "default").trim() !== "" && + element.subheader && + getLocalizedValue(element.subheader, "default").trim() !== "" && languages.length > 1 - ? isLabelValidForAllLanguages(question.subheader, languages) + ? isLabelValidForAllLanguages(element.subheader, languages) : true; let isValid = isHeadlineValid && isSubheaderValid; const defaultLanguageCode = "default"; - //question specific fields - let fieldsToValidate = ["buttonLabel", "upperLabel", "backButtonLabel", "lowerLabel"]; - - // Remove backButtonLabel from validation if it is the first question - if (isFirstQuestion) { - fieldsToValidate = fieldsToValidate.filter((field) => field !== "backButtonLabel"); - } - - if ((question.type === "nps" || question.type === "rating") && question.required) { - fieldsToValidate = fieldsToValidate.filter((field) => field !== "buttonLabel"); - } + // Element specific fields (note: buttonLabel and backButtonLabel are now block-level, not element-level) + let fieldsToValidate = ["upperLabel", "lowerLabel"]; for (const field of fieldsToValidate) { if ( - question[field] && - typeof question[field][defaultLanguageCode] !== "undefined" && - question[field][defaultLanguageCode].trim() !== "" + element[field] && + typeof element[field][defaultLanguageCode] !== "undefined" && + element[field][defaultLanguageCode].trim() !== "" ) { - isValid = isValid && isLabelValidForAllLanguages(question[field], languages); + isValid = isValid && isLabelValidForAllLanguages(element[field], languages); } } @@ -171,38 +164,33 @@ export const validationRules = { }; // Main validation function -export const validateQuestion = ( - question: TSurveyQuestion, - surveyLanguages: TSurveyLanguage[], - isFirstQuestion: boolean -): boolean => { - const specificValidation = validationRules[question.type]; +export const validateElement = (element: TSurveyElement, surveyLanguages: TSurveyLanguage[]): boolean => { + const specificValidation = validationRules[element.type]; const defaultValidation = validationRules.defaultValidation; - const specificValidationResult = specificValidation ? specificValidation(question, surveyLanguages) : true; - const defaultValidationResult = defaultValidation(question, surveyLanguages, isFirstQuestion); + const specificValidationResult = specificValidation ? specificValidation(element, surveyLanguages) : true; + const defaultValidationResult = defaultValidation(element, surveyLanguages); // Return true only if both specific and default validation pass return specificValidationResult && defaultValidationResult; }; -export const validateSurveyQuestionsInBatch = ( - question: TSurveyQuestion, - invalidQuestions: string[] | null, - surveyLanguages: TSurveyLanguage[], - isFirstQuestion: boolean +export const validateSurveyElementsInBatch = ( + element: TSurveyElement, + invalidElements: string[] | null, + surveyLanguages: TSurveyLanguage[] ) => { - if (invalidQuestions === null) { + if (invalidElements === null) { return []; } - if (validateQuestion(question, surveyLanguages, isFirstQuestion)) { - return invalidQuestions.filter((id) => id !== question.id); - } else if (!invalidQuestions.includes(question.id)) { - return [...invalidQuestions, question.id]; + if (validateElement(element, surveyLanguages)) { + return invalidElements.filter((id) => id !== element.id); + } else if (!invalidElements.includes(element.id)) { + return [...invalidElements, element.id]; } - return invalidQuestions; + return invalidElements; }; const isContentValid = (content: Record | undefined, surveyLanguages: TSurveyLanguage[]) => { diff --git a/apps/web/modules/survey/lib/questions.tsx b/apps/web/modules/survey/lib/questions.tsx index 0ed470f35b..d2d52c9c68 100644 --- a/apps/web/modules/survey/lib/questions.tsx +++ b/apps/web/modules/survey/lib/questions.tsx @@ -20,23 +20,7 @@ import { StarIcon, } from "lucide-react"; import type { JSX } from "react"; -import { - TSurveyAddressElement, - TSurveyCTAElement, - TSurveyCalElement, - TSurveyConsentElement, - TSurveyContactInfoElement, - TSurveyDateElement, - TSurveyElementTypeEnum, - TSurveyFileUploadElement, - TSurveyMatrixElement, - TSurveyMultipleChoiceElement, - TSurveyNPSElement, - TSurveyOpenTextElement, - TSurveyPictureSelectionElement, - TSurveyRankingElement, - TSurveyRatingElement, -} from "@formbricks/types/surveys/elements"; +import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { createI18nString } from "@/lib/i18n/utils"; import { replaceElementPresetPlaceholders } from "@/lib/utils/templates"; @@ -59,9 +43,7 @@ export const getQuestionTypes = (t: TFunction): TQuestion[] => [ placeholder: createI18nString(t("templates.free_text_placeholder"), []), longAnswer: true, inputType: "text", - buttonLabel: createI18nString(t("templates.next"), []), - backButtonLabel: createI18nString(t("templates.back"), []), - } as Partial, + }, }, { id: TSurveyElementTypeEnum.MultipleChoiceSingle, @@ -81,9 +63,7 @@ export const getQuestionTypes = (t: TFunction): TQuestion[] => [ }, ], shuffleOption: "none", - buttonLabel: createI18nString(t("templates.next"), []), - backButtonLabel: createI18nString(t("templates.back"), []), - } as Partial, + }, }, { id: TSurveyElementTypeEnum.MultipleChoiceMulti, @@ -107,9 +87,7 @@ export const getQuestionTypes = (t: TFunction): TQuestion[] => [ }, ], shuffleOption: "none", - buttonLabel: createI18nString(t("templates.next"), []), - backButtonLabel: createI18nString(t("templates.back"), []), - } as Partial, + }, }, { id: TSurveyElementTypeEnum.PictureSelection, @@ -120,9 +98,7 @@ export const getQuestionTypes = (t: TFunction): TQuestion[] => [ headline: createI18nString("", []), allowMulti: true, choices: [], - buttonLabel: createI18nString(t("templates.next"), []), - backButtonLabel: createI18nString(t("templates.back"), []), - } as Partial, + }, }, { id: TSurveyElementTypeEnum.Rating, @@ -135,9 +111,7 @@ export const getQuestionTypes = (t: TFunction): TQuestion[] => [ range: 5, lowerLabel: createI18nString(t("templates.rating_lower_label"), []), upperLabel: createI18nString(t("templates.rating_upper_label"), []), - buttonLabel: createI18nString(t("templates.next"), []), - backButtonLabel: createI18nString(t("templates.back"), []), - } as Partial, + }, }, { id: TSurveyElementTypeEnum.NPS, @@ -148,9 +122,7 @@ export const getQuestionTypes = (t: TFunction): TQuestion[] => [ headline: createI18nString("", []), lowerLabel: createI18nString(t("templates.nps_lower_label"), []), upperLabel: createI18nString(t("templates.nps_upper_label"), []), - buttonLabel: createI18nString(t("templates.next"), []), - backButtonLabel: createI18nString(t("templates.back"), []), - } as Partial, + }, }, { id: TSurveyElementTypeEnum.Ranking, @@ -169,9 +141,7 @@ export const getQuestionTypes = (t: TFunction): TQuestion[] => [ label: createI18nString("", []), }, ], - buttonLabel: createI18nString(t("templates.next"), []), - backButtonLabel: createI18nString(t("templates.back"), []), - } as Partial, + }, }, { id: TSurveyElementTypeEnum.Matrix, @@ -188,10 +158,8 @@ export const getQuestionTypes = (t: TFunction): TQuestion[] => [ { id: createId(), label: createI18nString("", []) }, { id: createId(), label: createI18nString("", []) }, ], - buttonLabel: createI18nString(t("templates.next"), []), - backButtonLabel: createI18nString(t("templates.back"), []), shuffleOption: "none", - } as Partial, + }, }, { id: TSurveyElementTypeEnum.CTA, @@ -201,11 +169,9 @@ export const getQuestionTypes = (t: TFunction): TQuestion[] => [ preset: { headline: createI18nString("", []), subheader: createI18nString("", []), - buttonLabel: createI18nString(t("templates.book_interview"), []), - buttonExternal: false, - dismissButtonLabel: createI18nString(t("templates.skip"), []), - backButtonLabel: createI18nString(t("templates.back"), []), - } as Partial, + ctaButtonLabel: createI18nString(t("templates.book_interview"), []), + buttonUrl: "", + }, }, { id: TSurveyElementTypeEnum.Consent, @@ -216,9 +182,7 @@ export const getQuestionTypes = (t: TFunction): TQuestion[] => [ headline: createI18nString("", []), subheader: createI18nString("", []), label: createI18nString("", []), - buttonLabel: createI18nString(t("templates.next"), []), - backButtonLabel: createI18nString(t("templates.back"), []), - } as Partial, + }, }, { id: TSurveyElementTypeEnum.FileUpload, @@ -228,9 +192,7 @@ export const getQuestionTypes = (t: TFunction): TQuestion[] => [ preset: { headline: createI18nString("", []), allowMultipleFiles: false, - buttonLabel: createI18nString(t("templates.next"), []), - backButtonLabel: createI18nString(t("templates.back"), []), - } as Partial, + }, }, { id: TSurveyElementTypeEnum.Date, @@ -240,9 +202,7 @@ export const getQuestionTypes = (t: TFunction): TQuestion[] => [ preset: { headline: createI18nString("", []), format: "M-d-y", - buttonLabel: createI18nString(t("templates.next"), []), - backButtonLabel: createI18nString(t("templates.back"), []), - } as Partial, + }, }, { id: TSurveyElementTypeEnum.Cal, @@ -252,9 +212,7 @@ export const getQuestionTypes = (t: TFunction): TQuestion[] => [ preset: { headline: createI18nString("", []), calUserName: "rick/get-rick-rolled", - buttonLabel: createI18nString(t("templates.next"), []), - backButtonLabel: createI18nString(t("templates.back"), []), - } as Partial, + }, }, { id: TSurveyElementTypeEnum.Address, @@ -269,9 +227,7 @@ export const getQuestionTypes = (t: TFunction): TQuestion[] => [ state: { show: true, required: true, placeholder: { default: "State" } }, zip: { show: true, required: true, placeholder: { default: "Zip" } }, country: { show: true, required: true, placeholder: { default: "Country" } }, - buttonLabel: createI18nString(t("templates.next"), []), - backButtonLabel: createI18nString(t("templates.back"), []), - } as Partial, + }, }, { id: TSurveyElementTypeEnum.ContactInfo, @@ -285,9 +241,7 @@ export const getQuestionTypes = (t: TFunction): TQuestion[] => [ email: { show: true, required: true, placeholder: { default: "Email" } }, phone: { show: true, required: true, placeholder: { default: "Phone" } }, company: { show: true, required: true, placeholder: { default: "Company" } }, - buttonLabel: createI18nString(t("templates.next"), []), - backButtonLabel: createI18nString(t("templates.back"), []), - } as Partial, + }, }, ]; diff --git a/apps/web/modules/survey/link/components/link-survey.tsx b/apps/web/modules/survey/link/components/link-survey.tsx index 1006785ce6..bbb3205d7b 100644 --- a/apps/web/modules/survey/link/components/link-survey.tsx +++ b/apps/web/modules/survey/link/components/link-survey.tsx @@ -12,7 +12,7 @@ import { VerifyEmail } from "@/modules/survey/link/components/verify-email"; import { getPrefillValue } from "@/modules/survey/link/lib/utils"; import { SurveyInline } from "@/modules/ui/components/survey"; -let setQuestionId = (_: string) => {}; +let setBlockId = (_: string) => {}; let setResponseData = (_: TResponseData) => {}; interface LinkSurveyProps { @@ -158,7 +158,11 @@ export const LinkSurvey = ({ }; const handleResetSurvey = () => { - setQuestionId(survey.welcomeCard.enabled ? "start" : questions[0].id); + if (survey.welcomeCard.enabled) { + setBlockId("start"); + } else if (survey.blocks[0]) { + setBlockId(survey.blocks[0].id); + } setResponseData({}); }; @@ -191,8 +195,8 @@ export const LinkSurvey = ({ prefillResponseData={prefillValue} skipPrefilled={skipPrefilled} responseCount={responseCount} - getSetQuestionId={(f: (value: string) => void) => { - setQuestionId = f; + getSetBlockId={(f: (value: string) => void) => { + setBlockId = f; }} getSetResponseData={(f: (value: TResponseData) => void) => { setResponseData = f; diff --git a/apps/web/modules/ui/components/preview-survey/index.tsx b/apps/web/modules/ui/components/preview-survey/index.tsx index 804c536717..ec995f91c1 100644 --- a/apps/web/modules/ui/components/preview-survey/index.tsx +++ b/apps/web/modules/ui/components/preview-survey/index.tsx @@ -7,7 +7,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { TProjectStyling } from "@formbricks/types/project"; import { TSurvey, TSurveyQuestionId, TSurveyStyling } from "@formbricks/types/surveys/types"; -import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils"; import { ClientLogo } from "@/modules/ui/components/client-logo"; import { MediaBackground } from "@/modules/ui/components/media-background"; import { ResetProgressButton } from "@/modules/ui/components/reset-progress-button"; @@ -56,7 +55,7 @@ const previewParentContainerVariant: Variants = { }, }; -let setQuestionId = (_: string) => {}; +let setBlockId = (_: string) => {}; export const PreviewSurvey = ({ questionId, @@ -78,8 +77,6 @@ export const PreviewSurvey = ({ const [shrink, setShrink] = useState(false); const { projectOverwrites } = survey || {}; - const questions = useMemo(() => getElementsFromBlocks(survey.blocks), [survey.blocks]); - const previewScreenVariants: Variants = { expanded: { right: "5%", @@ -153,9 +150,27 @@ export const PreviewSurvey = ({ ) return; if (newQuestionId === "start" && !survey.welcomeCard.enabled) return; - setQuestionId(newQuestionId); + + if (newQuestionId === "start") { + setBlockId("start"); + return; + } + + // Convert questionId to blockId and set it directly + const block = survey.blocks.find((b) => b.elements.some((e) => e.id === newQuestionId)); + if (block) { + setBlockId(block.id); + return; + } + + // check the endings + const ending = survey.endings.find((e) => e.id === newQuestionId); + if (ending) { + setBlockId(ending.id); + return; + } }, - [survey.welcomeCard.enabled] + [survey.welcomeCard.enabled, survey.blocks, survey.endings] ); useEffect(() => { @@ -169,7 +184,9 @@ export const PreviewSurvey = ({ if (survey.type === "app" && survey.endings.length === 0) { setIsModalOpen(false); setTimeout(() => { - setQuestionId(questions[0]?.id); + if (survey.blocks[0]) { + setBlockId(survey.blocks[0].id); + } setIsModalOpen(true); }, 500); } @@ -191,7 +208,11 @@ export const PreviewSurvey = ({ setPreviewMode(storePreviewMode); }, 10); - setQuestionId(survey.welcomeCard.enabled ? "start" : survey?.questions[0]?.id); + if (survey.welcomeCard.enabled) { + setBlockId("start"); + } else if (survey.blocks[0]) { + setBlockId(survey.blocks[0].id); + } }; useEffect(() => { @@ -224,7 +245,7 @@ export const PreviewSurvey = ({ setPreviewMode(mode); requestAnimationFrame(() => { if (questionId) { - setQuestionId(questionId); + updateQuestionId(questionId); } }); }; @@ -277,8 +298,8 @@ export const PreviewSurvey = ({ styling={styling} isCardBorderVisible={!styling.highlightBorderColor?.light} onClose={handlePreviewModalClose} - getSetQuestionId={(f: (value: string) => void) => { - setQuestionId = f; + getSetBlockId={(f: (value: string) => void) => { + setBlockId = f; }} onFinished={onFinished} isSpamProtectionEnabled={isSpamProtectionEnabled} @@ -299,8 +320,8 @@ export const PreviewSurvey = ({ languageCode={languageCode} responseCount={42} styling={styling} - getSetQuestionId={(f: (value: string) => void) => { - setQuestionId = f; + getSetBlockId={(f: (value: string) => void) => { + setBlockId = f; }} isSpamProtectionEnabled={isSpamProtectionEnabled} /> @@ -381,8 +402,8 @@ export const PreviewSurvey = ({ styling={styling} isCardBorderVisible={!styling.highlightBorderColor?.light} onClose={handlePreviewModalClose} - getSetQuestionId={(f: (value: string) => void) => { - setQuestionId = f; + getSetBlockId={(f: (value: string) => void) => { + setBlockId = f; }} onFinished={onFinished} isSpamProtectionEnabled={isSpamProtectionEnabled} @@ -408,8 +429,8 @@ export const PreviewSurvey = ({ languageCode={languageCode} responseCount={42} styling={styling} - getSetQuestionId={(f: (value: string) => void) => { - setQuestionId = f; + getSetBlockId={(f: (value: string) => void) => { + setBlockId = f; }} isSpamProtectionEnabled={isSpamProtectionEnabled} /> 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 e6d06dbd67..057fe8e06d 100644 --- a/apps/web/modules/ui/components/question-toggle-table/index.tsx +++ b/apps/web/modules/ui/components/question-toggle-table/index.tsx @@ -2,7 +2,8 @@ import { useTranslation } from "react-i18next"; import { TI18nString } from "@formbricks/types/i18n"; -import { TSurvey, TSurveyAddressQuestion, TSurveyContactInfoQuestion } from "@formbricks/types/surveys/types"; +import { TSurveyAddressElement, TSurveyContactInfoElement } from "@formbricks/types/surveys/elements"; +import { TSurvey } 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"; @@ -21,7 +22,7 @@ interface QuestionToggleTableProps { isInvalid: boolean; updateQuestion: ( questionIdx: number, - updatedAttributes: Partial + updatedAttributes: Partial ) => void; selectedLanguageCode: string; setSelectedLanguageCode: (languageCode: string) => void; diff --git a/apps/web/modules/ui/components/shuffle-option-select/index.tsx b/apps/web/modules/ui/components/shuffle-option-select/index.tsx index 162b9ee611..0bcbc6ab54 100644 --- a/apps/web/modules/ui/components/shuffle-option-select/index.tsx +++ b/apps/web/modules/ui/components/shuffle-option-select/index.tsx @@ -2,11 +2,11 @@ import { useTranslation } from "react-i18next"; import { - TShuffleOption, - TSurveyMatrixQuestion, - TSurveyMultipleChoiceQuestion, - TSurveyRankingQuestion, -} from "@formbricks/types/surveys/types"; + TSurveyMatrixElement, + TSurveyMultipleChoiceElement, + TSurveyRankingElement, +} from "@formbricks/types/surveys/elements"; +import { TShuffleOption } from "@formbricks/types/surveys/types"; import { Select, SelectContent, @@ -31,7 +31,7 @@ interface ShuffleOptionSelectProps { shuffleOption: TShuffleOption | undefined; updateQuestion: ( questionIdx: number, - updatedAttributes: Partial + updatedAttributes: Partial ) => void; questionIdx: number; shuffleOptionsTypes: ShuffleOptionsTypes; diff --git a/packages/js-core/src/types/survey.ts b/packages/js-core/src/types/survey.ts index 011cb0ed14..436f196e85 100644 --- a/packages/js-core/src/types/survey.ts +++ b/packages/js-core/src/types/survey.ts @@ -8,7 +8,6 @@ export interface SurveyBaseProps { isBrandingEnabled: boolean; getSetIsError?: (getSetError: (value: boolean) => void) => void; getSetIsResponseSendingFinished?: (getSetIsResponseSendingFinished: (value: boolean) => void) => void; - getSetQuestionId?: (getSetQuestionId: (value: string) => void) => void; getSetResponseData?: (getSetResponseData: (value: TResponseData) => void) => void; onDisplay?: () => void; onResponse?: (response: TResponseUpdate) => void; diff --git a/packages/surveys/src/components/general/block-conditional.tsx b/packages/surveys/src/components/general/block-conditional.tsx new file mode 100644 index 0000000000..f6dc6955fc --- /dev/null +++ b/packages/surveys/src/components/general/block-conditional.tsx @@ -0,0 +1,266 @@ +import { useEffect, useRef, useState } from "preact/hooks"; +import { type TJsFileUploadParams } from "@formbricks/types/js"; +import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; +import { type TUploadFileConfig } from "@formbricks/types/storage"; +import { TSurveyBlock } from "@formbricks/types/surveys/blocks"; +import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; +import { BackButton } from "@/components/buttons/back-button"; +import { SubmitButton } from "@/components/buttons/submit-button"; +import { ElementConditional } from "@/components/general/element-conditional"; +import { ScrollableContainer } from "@/components/wrappers/scrollable-container"; +import { getLocalizedValue } from "@/lib/i18n"; +import { cn } from "@/lib/utils"; + +interface BlockConditionalProps { + block: TSurveyBlock; + value: TResponseData; + onChange: (responseData: TResponseData) => void; + onSubmit: (data: TResponseData, ttc: TResponseTtc) => void; + onBack: () => void; + onFileUpload: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise; + isFirstBlock: boolean; + isLastBlock: boolean; + languageCode: string; + prefilledResponseData?: TResponseData; + skipPrefilled?: boolean; + ttc: TResponseTtc; + setTtc: (ttc: TResponseTtc) => void; + surveyId: string; + autoFocusEnabled: boolean; + currentBlockId: string; + isBackButtonHidden: boolean; + onOpenExternalURL?: (url: string) => void | Promise; + dir?: "ltr" | "rtl" | "auto"; + fullSizeCards: boolean; +} + +export function BlockConditional({ + block, + value, + onChange, + onSubmit, + onBack, + isFirstBlock, + isLastBlock, + languageCode, + prefilledResponseData, + skipPrefilled, + ttc, + setTtc, + surveyId, + onFileUpload, + autoFocusEnabled, + isBackButtonHidden, + onOpenExternalURL, + dir, + fullSizeCards, +}: BlockConditionalProps) { + // Track the current element being filled (for TTC tracking) + const [currentElementId, setCurrentElementId] = useState(block.elements[0]?.id); + + // Refs to store form elements for each element so we can trigger their validation + const elementFormRefs = useRef>(new Map()); + + // Handle change for an individual element + const handleElementChange = (elementId: string, responseData: TResponseData) => { + // If user moved to a different element, we should track it + if (elementId !== currentElementId) { + setCurrentElementId(elementId); + } + onChange(responseData); + }; + + // Handle skipPrefilled at block level + useEffect(() => { + if (skipPrefilled && prefilledResponseData) { + // Check if ALL elements in this block have prefilled values + const allElementsPrefilled = block.elements.every( + (element) => prefilledResponseData[element.id] !== undefined + ); + + if (allElementsPrefilled) { + // Auto-populate all prefilled values + const prefilledData: TResponseData = {}; + const prefilledTtc: TResponseTtc = {}; + + block.elements.forEach((element) => { + prefilledData[element.id] = prefilledResponseData[element.id]; + prefilledTtc[element.id] = 0; // 0 TTC for prefilled/skipped questions + }); + + // Update state with prefilled data + onChange(prefilledData); + setTtc({ ...ttc, ...prefilledTtc }); + + // Auto-submit the entire block (skip to next) + setTimeout(() => { + onSubmit(prefilledData, prefilledTtc); + }, 0); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- Only run once when block mounts + }, []); + + const handleBlockSubmit = (e?: Event) => { + if (e) { + e.preventDefault(); + } + + // Validate all forms and check for custom validation rules + let firstInvalidForm: HTMLFormElement | null = null; + + for (const element of block.elements) { + const form = elementFormRefs.current.get(element.id); + if (form) { + // Check HTML5 validity first + if (!form.checkValidity()) { + if (!firstInvalidForm) { + firstInvalidForm = form; + } + form.reportValidity(); + continue; + } + + // Custom validation for ranking questions + if (element.type === TSurveyElementTypeEnum.Ranking) { + const response = value[element.id]; + const rankingElement = element; + + // Check if ranking is incomplete + const hasIncompleteRanking = + (rankingElement.required && + (!Array.isArray(response) || response.length !== rankingElement.choices.length)) || + (!rankingElement.required && + Array.isArray(response) && + response.length > 0 && + response.length < rankingElement.choices.length); + + if (hasIncompleteRanking) { + // Trigger the ranking form's submit to show the error message + form.requestSubmit(); + if (!firstInvalidForm) { + firstInvalidForm = form; + } + continue; + } + } + + // For other element types, check if required fields are empty + if (element.required) { + const response = value[element.id]; + const isEmpty = + response === undefined || + response === null || + response === "" || + (Array.isArray(response) && response.length === 0) || + (typeof response === "object" && !Array.isArray(response) && Object.keys(response).length === 0); + + if (isEmpty) { + form.requestSubmit(); + if (!firstInvalidForm) { + firstInvalidForm = form; + } + continue; + } + } + } + } + + // If any form is invalid, scroll to it and stop + if (firstInvalidForm) { + firstInvalidForm.scrollIntoView({ behavior: "smooth", block: "center" }); + return; + } + + // All validations passed - collect TTC for all elements in this block + const blockTtc: TResponseTtc = {}; + block.elements.forEach((element) => { + if (ttc[element.id] !== undefined) { + blockTtc[element.id] = ttc[element.id]; + } + }); + + // Collect responses for all elements in this block + const blockResponses: TResponseData = {}; + block.elements.forEach((element) => { + if (value[element.id] !== undefined) { + blockResponses[element.id] = value[element.id]; + } + }); + + onSubmit(blockResponses, blockTtc); + }; + + return ( +
+ {/* Scrollable container for the entire block */} + +
+
+ {block.elements.map((element, index) => { + const isFirstElement = index === 0; + + return ( +
+ handleElementChange(element.id, responseData)} + onBack={() => {}} + onFileUpload={onFileUpload} + isFirstElement={false} + isLastElement={false} + languageCode={languageCode} + prefilledElementValue={prefilledResponseData?.[element.id]} + skipPrefilled={skipPrefilled} + ttc={ttc} + setTtc={setTtc} + surveyId={surveyId} + autoFocusEnabled={autoFocusEnabled && isFirstElement} + currentElementId={currentElementId} + isBackButtonHidden={true} + onOpenExternalURL={onOpenExternalURL} + dir={dir} + formRef={(ref) => { + if (ref) { + elementFormRefs.current.set(element.id, ref); + } else { + elementFormRefs.current.delete(element.id); + } + }} + /> +
+ ); + })} +
+ +
+
+ +
+ {!isFirstBlock && !isBackButtonHidden && ( + + )} +
+
+
+
+ ); +} diff --git a/packages/surveys/src/components/general/element-conditional.tsx b/packages/surveys/src/components/general/element-conditional.tsx new file mode 100644 index 0000000000..030f0f5262 --- /dev/null +++ b/packages/surveys/src/components/general/element-conditional.tsx @@ -0,0 +1,340 @@ +import { useEffect, useRef } from "preact/hooks"; +import { type TJsFileUploadParams } from "@formbricks/types/js"; +import { type TResponseData, type TResponseDataValue, type TResponseTtc } from "@formbricks/types/responses"; +import { type TUploadFileConfig } from "@formbricks/types/storage"; +import { + TSurveyElement, + TSurveyElementChoice, + TSurveyElementTypeEnum, +} from "@formbricks/types/surveys/elements"; +import { AddressQuestion } from "@/components/questions/address-question"; +import { CalQuestion } from "@/components/questions/cal-question"; +import { ConsentQuestion } from "@/components/questions/consent-question"; +import { ContactInfoQuestion } from "@/components/questions/contact-info-question"; +import { CTAQuestion } from "@/components/questions/cta-question"; +import { DateQuestion } from "@/components/questions/date-question"; +import { FileUploadQuestion } from "@/components/questions/file-upload-question"; +import { MatrixQuestion } from "@/components/questions/matrix-question"; +import { MultipleChoiceMultiQuestion } from "@/components/questions/multiple-choice-multi-question"; +import { MultipleChoiceSingleQuestion } from "@/components/questions/multiple-choice-single-question"; +import { NPSQuestion } from "@/components/questions/nps-question"; +import { OpenTextQuestion } from "@/components/questions/open-text-question"; +import { PictureSelectionQuestion } from "@/components/questions/picture-selection-question"; +import { RankingQuestion } from "@/components/questions/ranking-question"; +import { RatingQuestion } from "@/components/questions/rating-question"; +import { getLocalizedValue } from "@/lib/i18n"; + +interface ElementConditionalProps { + element: TSurveyElement; + value: TResponseDataValue; + onChange: (responseData: TResponseData) => void; + onBack: () => void; + onFileUpload: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise; + isFirstElement: boolean; + isLastElement: boolean; + languageCode: string; + prefilledElementValue?: TResponseDataValue; + skipPrefilled?: boolean; + ttc: TResponseTtc; + setTtc: (ttc: TResponseTtc) => void; + surveyId: string; + autoFocusEnabled: boolean; + currentElementId: string; + isBackButtonHidden: boolean; + onOpenExternalURL?: (url: string) => void | Promise; + dir?: "ltr" | "rtl" | "auto"; + formRef?: (ref: HTMLFormElement | null) => void; // Callback to expose the form element +} + +export function ElementConditional({ + element, + value, + onChange, + languageCode, + prefilledElementValue, + skipPrefilled, + ttc, + setTtc, + surveyId, + onFileUpload, + autoFocusEnabled, + currentElementId, + onOpenExternalURL, + dir, + formRef, +}: ElementConditionalProps) { + // Ref to the container div, used to find and expose the form element inside + const containerRef = useRef(null); + + // Expose the form element to parent via callback + useEffect(() => { + if (formRef && containerRef.current) { + const form = containerRef.current.querySelector("form"); + formRef(form); + + // Cleanup: pass null when unmounting + return () => formRef(null); + } + }, [formRef]); + + const getResponseValueForRankingQuestion = (value: string[], choices: TSurveyElementChoice[]): string[] => { + return value + .map((entry) => { + // First check if entry is already a valid choice ID + if (choices.some((c) => c.id === entry)) { + return entry; + } + // Otherwise, treat it as a localized label and find the choice by label + return choices.find((choice) => getLocalizedValue(choice.label, languageCode) === entry)?.id; + }) + .filter((id): id is TSurveyElementChoice["id"] => id !== undefined); + }; + + useEffect(() => { + if (value === undefined && (prefilledElementValue || prefilledElementValue === "")) { + if (!skipPrefilled) { + onChange({ [element.id]: prefilledElementValue }); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- we want to run this only once when the element renders for the first time + }, []); + + const isRecognizedType = Object.values(TSurveyElementTypeEnum).includes(element.type); + + useEffect(() => { + if (!isRecognizedType) { + console.warn( + `[Formbricks] Unrecognized element type "${element.type}" for element with id "${element.id}". No component will be rendered.` + ); + } + }, [element.type, element.id, isRecognizedType]); + + if (!isRecognizedType) { + return null; + } + + const renderElement = () => { + switch (element.type) { + case TSurveyElementTypeEnum.OpenText: + return ( + + ); + case TSurveyElementTypeEnum.MultipleChoiceSingle: + return ( + + ); + case TSurveyElementTypeEnum.MultipleChoiceMulti: + return ( + + ); + case TSurveyElementTypeEnum.NPS: + return ( + + ); + case TSurveyElementTypeEnum.CTA: + return ( + + ); + case TSurveyElementTypeEnum.Rating: + return ( + + ); + case TSurveyElementTypeEnum.Consent: + return ( + + ); + case TSurveyElementTypeEnum.Date: + return ( + + ); + case TSurveyElementTypeEnum.PictureSelection: + return ( + + ); + case TSurveyElementTypeEnum.FileUpload: + return ( + + ); + case TSurveyElementTypeEnum.Cal: + return ( + + ); + case TSurveyElementTypeEnum.Matrix: + return ( + + ); + case TSurveyElementTypeEnum.Address: + return ( + + ); + case TSurveyElementTypeEnum.Ranking: + return ( + + ); + case TSurveyElementTypeEnum.ContactInfo: + return ( + + ); + default: + return null; + } + }; + + return
{renderElement()}
; +} diff --git a/packages/surveys/src/components/general/progress-bar.tsx b/packages/surveys/src/components/general/progress-bar.tsx index 2567ca6581..ec24626c9b 100644 --- a/packages/surveys/src/components/general/progress-bar.tsx +++ b/packages/surveys/src/components/general/progress-bar.tsx @@ -1,48 +1,41 @@ import { useCallback, useMemo } from "preact/hooks"; import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js"; import { Progress } from "@/components/general/progress"; -import { calculateElementIdx, getElementsFromSurvey } from "@/lib/utils"; interface ProgressBarProps { survey: TJsEnvironmentStateSurvey; - questionId: string; + blockId: string; } -export function ProgressBar({ survey, questionId }: ProgressBarProps) { - const questions = useMemo(() => getElementsFromSurvey(survey), [survey]); - const currentQuestionIdx = useMemo( - () => questions.findIndex((q) => q.id === questionId), - [questions, questionId] +export function ProgressBar({ survey, blockId }: ProgressBarProps) { + const currentBlockIdx = useMemo( + () => survey.blocks.findIndex((b) => b.id === blockId), + [survey.blocks, blockId] ); const endingCardIds = useMemo(() => survey.endings.map((ending) => ending.id), [survey.endings]); const calculateProgress = useCallback( - (index: number) => { + (blockIndex: number) => { let totalCards = survey.blocks.length; if (endingCardIds.length > 0) totalCards += 1; - let idx = index; - if (index === -1) idx = 0; + if (blockIndex === -1) return 0; // Welcome card - const elementIdx = calculateElementIdx(survey, idx, totalCards); - return elementIdx / totalCards; + // Progress is simply block position / total blocks + return (blockIndex + 1) / totalCards; }, - [survey, endingCardIds.length] + [survey.blocks.length, endingCardIds.length] ); - const progressArray = useMemo(() => { - return questions.map((_, index) => calculateProgress(index)); - }, [calculateProgress, questions]); - const progressValue = useMemo(() => { - if (questionId === "start") { + if (blockId === "start") { return 0; - } else if (endingCardIds.includes(questionId)) { + } else if (endingCardIds.includes(blockId)) { return 1; } - return progressArray[currentQuestionIdx]; - }, [questionId, endingCardIds, progressArray, currentQuestionIdx]); + return calculateProgress(currentBlockIdx); + }, [blockId, endingCardIds, calculateProgress, currentBlockIdx]); return ; } diff --git a/packages/surveys/src/components/general/question-conditional.tsx b/packages/surveys/src/components/general/question-conditional.tsx deleted file mode 100644 index f2f708fa4a..0000000000 --- a/packages/surveys/src/components/general/question-conditional.tsx +++ /dev/null @@ -1,399 +0,0 @@ -import { useEffect, useMemo } from "react"; -import { type TJsEnvironmentStateSurvey, type TJsFileUploadParams } from "@formbricks/types/js"; -import { type TResponseData, type TResponseDataValue, type TResponseTtc } from "@formbricks/types/responses"; -import { type TUploadFileConfig } from "@formbricks/types/storage"; -import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; -import { type TSurveyQuestionChoice } from "@formbricks/types/surveys/types"; -import { AddressQuestion } from "@/components/questions/address-question"; -import { CalQuestion } from "@/components/questions/cal-question"; -import { ConsentQuestion } from "@/components/questions/consent-question"; -import { ContactInfoQuestion } from "@/components/questions/contact-info-question"; -import { CTAQuestion } from "@/components/questions/cta-question"; -import { DateQuestion } from "@/components/questions/date-question"; -import { FileUploadQuestion } from "@/components/questions/file-upload-question"; -import { MatrixQuestion } from "@/components/questions/matrix-question"; -import { MultipleChoiceMultiQuestion } from "@/components/questions/multiple-choice-multi-question"; -import { MultipleChoiceSingleQuestion } from "@/components/questions/multiple-choice-single-question"; -import { NPSQuestion } from "@/components/questions/nps-question"; -import { OpenTextQuestion } from "@/components/questions/open-text-question"; -import { PictureSelectionQuestion } from "@/components/questions/picture-selection-question"; -import { RankingQuestion } from "@/components/questions/ranking-question"; -import { RatingQuestion } from "@/components/questions/rating-question"; -import { getLocalizedValue } from "@/lib/i18n"; -import { findBlockByElementId } from "@/lib/utils"; - -interface QuestionConditionalProps { - survey: TJsEnvironmentStateSurvey; - question: TSurveyElement; - value: TResponseDataValue; - onChange: (responseData: TResponseData) => void; - onSubmit: (data: TResponseData, ttc: TResponseTtc) => void; - onBack: () => void; - onFileUpload: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise; - isFirstQuestion: boolean; - isLastQuestion: boolean; - languageCode: string; - prefilledQuestionValue?: TResponseDataValue; - skipPrefilled?: boolean; - ttc: TResponseTtc; - setTtc: (ttc: TResponseTtc) => void; - surveyId: string; - autoFocusEnabled: boolean; - currentQuestionId: string; - isBackButtonHidden: boolean; - onOpenExternalURL?: (url: string) => void | Promise; - dir?: "ltr" | "rtl" | "auto"; - fullSizeCards: boolean; -} - -export function QuestionConditional({ - survey, - question, - value, - onChange, - onSubmit, - onBack, - isFirstQuestion, - isLastQuestion, - languageCode, - prefilledQuestionValue, - skipPrefilled, - ttc, - setTtc, - surveyId, - onFileUpload, - autoFocusEnabled, - currentQuestionId, - isBackButtonHidden, - onOpenExternalURL, - dir, - fullSizeCards, -}: QuestionConditionalProps) { - // Get block-level properties from the parent block - const parentBlock = useMemo(() => findBlockByElementId(survey, question.id), [survey, question.id]); - const buttonLabel = parentBlock?.buttonLabel; - const backButtonLabel = parentBlock?.backButtonLabel; - - const getResponseValueForRankingQuestion = ( - value: string[], - choices: TSurveyQuestionChoice[] - ): string[] => { - return value - .map((label) => choices.find((choice) => getLocalizedValue(choice.label, languageCode) === label)?.id) - .filter((id): id is TSurveyQuestionChoice["id"] => id !== undefined); - }; - - useEffect(() => { - if (value === undefined && (prefilledQuestionValue || prefilledQuestionValue === "")) { - if (skipPrefilled) { - onSubmit({ [question.id]: prefilledQuestionValue }, { [question.id]: 0 }); - } else { - onChange({ [question.id]: prefilledQuestionValue }); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- we want to run this only once when the question renders for the first time - }, []); - - return question.type === TSurveyElementTypeEnum.OpenText ? ( - - ) : question.type === TSurveyElementTypeEnum.MultipleChoiceSingle ? ( - - ) : question.type === TSurveyElementTypeEnum.MultipleChoiceMulti ? ( - - ) : question.type === TSurveyElementTypeEnum.NPS ? ( - - ) : question.type === TSurveyElementTypeEnum.CTA ? ( - - ) : question.type === TSurveyElementTypeEnum.Rating ? ( - - ) : question.type === TSurveyElementTypeEnum.Consent ? ( - - ) : question.type === TSurveyElementTypeEnum.Date ? ( - - ) : question.type === TSurveyElementTypeEnum.PictureSelection ? ( - - ) : question.type === TSurveyElementTypeEnum.FileUpload ? ( - - ) : question.type === TSurveyElementTypeEnum.Cal ? ( - - ) : question.type === TSurveyElementTypeEnum.Matrix ? ( - - ) : question.type === TSurveyElementTypeEnum.Address ? ( - - ) : question.type === TSurveyElementTypeEnum.Ranking ? ( - - ) : question.type === TSurveyElementTypeEnum.ContactInfo ? ( - - ) : null; -} diff --git a/packages/surveys/src/components/general/survey.tsx b/packages/surveys/src/components/general/survey.tsx index b563f12de7..ca5892719a 100644 --- a/packages/surveys/src/components/general/survey.tsx +++ b/packages/surveys/src/components/general/survey.tsx @@ -4,18 +4,17 @@ import { SurveyContainerProps } from "@formbricks/types/formbricks-surveys"; import { type TJsEnvironmentStateSurvey, TJsFileUploadParams } from "@formbricks/types/js"; import type { TResponseData, - TResponseDataValue, TResponseTtc, TResponseUpdate, TResponseVariables, } from "@formbricks/types/responses"; import { TUploadFileConfig } from "@formbricks/types/storage"; +import { BlockConditional } from "@/components/general/block-conditional"; import { EndingCard } from "@/components/general/ending-card"; import { ErrorComponent } from "@/components/general/error-component"; import { FormbricksBranding } from "@/components/general/formbricks-branding"; import { LanguageSwitch } from "@/components/general/language-switch"; import { ProgressBar } from "@/components/general/progress-bar"; -import { QuestionConditional } from "@/components/general/question-conditional"; import { RecaptchaBranding } from "@/components/general/recaptcha-branding"; import { ResponseErrorComponent } from "@/components/general/response-error-component"; import { SurveyCloseButton } from "@/components/general/survey-close-button"; @@ -27,13 +26,7 @@ import { evaluateLogic, performActions } from "@/lib/logic"; import { parseRecallInformation } from "@/lib/recall"; import { ResponseQueue } from "@/lib/response-queue"; import { SurveyState } from "@/lib/survey-state"; -import { - cn, - findBlockByElementId, - getDefaultLanguageCode, - getElementsFromSurvey, - getFirstElementIdInBlock, -} from "@/lib/utils"; +import { cn, findBlockByElementId, getDefaultLanguageCode, getElementsFromSurveyBlocks } from "@/lib/utils"; import { TResponseErrorCodesEnum } from "@/types/response-error-codes"; interface VariableStackEntry { @@ -65,7 +58,7 @@ export function Survey({ languageCode, getSetIsError, getSetIsResponseSendingFinished, - getSetQuestionId, + getSetBlockId, getSetResponseData, responseCount, startAtQuestionId, @@ -135,7 +128,7 @@ export function Survey({ if (quotaInfo.action === "endSurvey") { setIsResponseSendingFinished(true); setIsSurveyFinished(true); - setQuestionId(quotaInfo.endingCardId); + setBlockId(quotaInfo.endingCardId); } }, }, @@ -146,7 +139,7 @@ export function Survey({ return null; }, [appUrl, environmentId, getSetIsError, getSetIsResponseSendingFinished, surveyState]); - const questions = useMemo(() => getElementsFromSurvey(localSurvey), [localSurvey]); + const questions = useMemo(() => getElementsFromSurveyBlocks(localSurvey.blocks), [localSurvey.blocks]); const originalQuestionRequiredStates = useMemo(() => { return questions.reduce>((acc, question) => { @@ -175,14 +168,19 @@ export function Survey({ const autoFocusEnabled = autoFocus ?? window.self === window.top; - const [questionId, setQuestionId] = useState(() => { + // Block-based navigation: track current block ID instead of question ID + const [blockId, setBlockId] = useState(() => { if (startAtQuestionId) { - return startAtQuestionId; + // If starting at a specific question, find its parent block + const startBlock = findBlockByElementId(localSurvey.blocks, startAtQuestionId); + return startBlock?.id || localSurvey.blocks[0]?.id; } else if (localSurvey.welcomeCard.enabled) { return "start"; } - return questions[0]?.id; + + return localSurvey.blocks[0]?.id; }); + const [errorType, setErrorType] = useState(undefined); const [showError, setShowError] = useState(false); const [isResponseSendingFinished, setIsResponseSendingFinished] = useState( @@ -203,14 +201,16 @@ export function Survey({ return styling.cardArrangement?.appSurveys ?? "straight"; }, [localSurvey.type, styling.cardArrangement?.linkSurveys, styling.cardArrangement?.appSurveys]); - const currentQuestionIndex = questions.findIndex((q) => q.id === questionId); - const currentQuestion = questions[currentQuestionIndex]; + // Current block tracking (replaces currentQuestionIndex) + const currentBlockIndex = localSurvey.blocks.findIndex((b) => b.id === blockId); + const currentBlock = localSurvey.blocks[currentBlockIndex]; const contentRef = useRef(null); const showProgressBar = !styling.hideProgressBar; const getShowSurveyCloseButton = (offset: number) => { return offset === 0 && localSurvey.type !== "link"; }; + const getShowLanguageSwitch = (offset: number) => { return localSurvey.showLanguageSwitch && localSurvey.languages.length > 0 && offset <= 0; }; @@ -238,11 +238,11 @@ export function Survey({ }; useEffect(() => { - // scroll to top when question changes + // scroll to top when block changes if (contentRef.current) { contentRef.current.scrollTop = 0; } - }, [questionId]); + }, [blockId]); const createDisplay = useCallback(async () => { // Skip display creation in preview mode but still trigger the onDisplayCreated callback @@ -312,12 +312,12 @@ export function Survey({ }, [getSetIsError]); useEffect(() => { - if (getSetQuestionId) { - getSetQuestionId((value: string) => { - setQuestionId(value); + if (getSetBlockId) { + getSetBlockId((value: string) => { + setBlockId(value); }); } - }, [getSetQuestionId]); + }, [getSetBlockId]); useEffect(() => { if (getSetResponseData) { @@ -410,15 +410,23 @@ export function Survey({ }); }; - const evaluateLogicAndGetNextQuestionId = ( + const evaluateLogicAndGetNextBlockId = ( data: TResponseData - ): { nextQuestionId: string | undefined; calculatedVariables: TResponseVariables } => { + ): { nextBlockId: string | undefined; calculatedVariables: TResponseVariables } => { const firstEndingId = survey.endings.length > 0 ? survey.endings[0].id : undefined; - if (questionId === "start") - return { nextQuestionId: questions[0]?.id || firstEndingId, calculatedVariables: {} }; + if (blockId === "start") + return { nextBlockId: localSurvey.blocks[0]?.id || firstEndingId, calculatedVariables: {} }; - if (!currentQuestion) throw new Error("Question not found"); + if (!currentBlock) { + console.error( + "Block not found. blockId:", + blockId, + "available blocks:", + localSurvey.blocks.map((b) => b.id) + ); + throw new Error("Block not found"); + } let firstJumpTarget: string | undefined; const allRequiredQuestionIds: string[] = []; @@ -426,10 +434,9 @@ export function Survey({ let calculationResults = { ...currentVariables }; const localResponseData = { ...responseData, ...data }; - // Get logic from the parent block (logic is at block level, not element level) - const currentQuestionBlock = findBlockByElementId(localSurvey, currentQuestion.id); - if (currentQuestionBlock?.logic && currentQuestionBlock.logic.length > 0) { - for (const logic of currentQuestionBlock.logic) { + // Evaluate block-level logic + if (currentBlock.logic && currentBlock.logic.length > 0) { + for (const logic of currentBlock.logic) { if ( evaluateLogic( localSurvey, @@ -447,7 +454,7 @@ export function Survey({ ); if (jumpTarget && !firstJumpTarget) { - firstJumpTarget = jumpTarget; + firstJumpTarget = jumpTarget; // This is already a block ID from performActions } allRequiredQuestionIds.push(...requiredQuestionIds); @@ -457,32 +464,26 @@ export function Survey({ } // Use logicFallback if no jump target was set (logicFallback is at block level) - if (!firstJumpTarget && currentQuestionBlock?.logicFallback) { - firstJumpTarget = currentQuestionBlock.logicFallback; + if (!firstJumpTarget && currentBlock.logicFallback) { + firstJumpTarget = currentBlock.logicFallback; } if (allRequiredQuestionIds.length > 0) { - // Track which questions are being made required by this question - questionRequiredByMap.current[currentQuestion.id] = allRequiredQuestionIds; + // Track which questions are being made required by this block + if (currentBlock.elements[0]) { + questionRequiredByMap.current[currentBlock.elements[0].id] = allRequiredQuestionIds; + } makeQuestionsRequired(allRequiredQuestionIds); } - // Convert block ID to element ID if jumping to a block - // (performActions returns block IDs for jumpToBlock actions) - let resolvedJumpTarget = firstJumpTarget; - if (firstJumpTarget) { - // Try to convert block ID to element ID - const elementId = getFirstElementIdInBlock(localSurvey, firstJumpTarget); - if (elementId) { - resolvedJumpTarget = elementId; - } - } + // Return the jump target (which is a block ID) or the next block in sequence + const nextBlockId = firstJumpTarget || localSurvey.blocks[currentBlockIndex + 1]?.id; - // Return the first jump target if found, otherwise go to the next question or ending - const nextQuestionId = resolvedJumpTarget ?? questions[currentQuestionIndex + 1]?.id ?? firstEndingId; - - return { nextQuestionId, calculatedVariables: calculationResults }; + return { + nextBlockId, + calculatedVariables: calculationResults, + }; }; const getWebSurveyMeta = useCallback(() => { @@ -589,7 +590,10 @@ export function Survey({ }, [isResponseSendingFinished, isSurveyFinished, onFinished]); const onSubmit = async (surveyResponseData: TResponseData, responsettc: TResponseTtc) => { - const respondedQuestionId = Object.keys(surveyResponseData)[0]; + // Get the first responded element ID for tracking + const respondedElementIds = Object.keys(surveyResponseData); + const firstRespondedElementId = respondedElementIds[0]; + setLoadingElement(true); if (isSpamProtectionEnabled && !surveyState?.responseId && getRecaptchaToken) { @@ -604,16 +608,16 @@ export function Survey({ } } - pushVariableState(respondedQuestionId); + pushVariableState(firstRespondedElementId); - const { nextQuestionId, calculatedVariables } = evaluateLogicAndGetNextQuestionId(surveyResponseData); + const { nextBlockId, calculatedVariables } = evaluateLogicAndGetNextBlockId(surveyResponseData); const finished = - nextQuestionId === undefined || !questions.map((question) => question.id).includes(nextQuestionId); + nextBlockId === undefined || !localSurvey.blocks.map((block) => block.id).includes(nextBlockId); setIsSurveyFinished(finished); - const endingId = nextQuestionId - ? localSurvey.endings.find((ending) => ending.id === nextQuestionId)?.id + const endingId = nextBlockId + ? localSurvey.endings.find((ending) => ending.id === nextBlockId)?.id : undefined; onChange(surveyResponseData); @@ -628,40 +632,44 @@ export function Survey({ endingId, }); - if (nextQuestionId) { - setQuestionId(nextQuestionId); + if (nextBlockId) { + setBlockId(nextBlockId); + } else if (finished) { + // Survey is finished, show the first ending or set to a value > blocks.length + const firstEndingId = localSurvey.endings[0]?.id; + if (firstEndingId) { + setBlockId(firstEndingId); + } else { + // No endings defined, set blockId to trigger ending screen + setBlockId("end"); + } } - // add to history - setHistory([...history, respondedQuestionId]); + // add current block to history + setHistory([...history, blockId]); setLoadingElement(false); }; const onBack = (): void => { - let prevQuestionId; + let prevBlockId; // use history if available if (history.length > 0) { const newHistory = [...history]; - prevQuestionId = newHistory.pop(); + prevBlockId = newHistory.pop(); setHistory(newHistory); } else { - // otherwise go back to previous question in array - prevQuestionId = questions[currentQuestionIndex - 1]?.id; + // otherwise go back to previous block in array + prevBlockId = localSurvey.blocks[currentBlockIndex - 1]?.id; } popVariableState(); - if (!prevQuestionId) throw new Error("Question not found"); + if (!prevBlockId) throw new Error("Block not found"); - revertRequiredChangesByQuestion(prevQuestionId); - setQuestionId(prevQuestionId); - }; - - const getQuestionPrefillData = ( - prefillQuestionId: string, - offset: number - ): TResponseDataValue | undefined => { - if (offset === 0 && prefillResponseData) { - return prefillResponseData[prefillQuestionId]; + // Revert required changes by the first element in the previous block + const prevBlock = localSurvey.blocks.find((b) => b.id === prevBlockId); + if (prevBlock?.elements[0]) { + revertRequiredChangesByQuestion(prevBlock.elements[0].id); } - return undefined; + + setBlockId(prevBlockId); }; const retryResponse = () => { @@ -674,7 +682,7 @@ export function Survey({ } }; - const getCardContent = (questionIdx: number, offset: number): JSX.Element | undefined => { + const getCardContent = (blockIdx: number, offset: number): JSX.Element | undefined => { if (showError) { switch (errorType) { case TResponseErrorCodesEnum.ResponseSendingError: @@ -701,7 +709,7 @@ export function Survey({ } const content = () => { - if (questionIdx === -1) { + if (blockIdx === -1) { return ( ); - } else if (questionIdx >= questions.length) { + } else if (blockIdx >= localSurvey.blocks.length) { const endingCard = localSurvey.endings.find((ending) => { - return ending.id === questionId; + return ending.id === blockId; }); if (endingCard) { return ( @@ -744,28 +752,32 @@ export function Survey({ ); } } else { - const question = questions[questionIdx]; + const block = localSurvey.blocks[blockIdx]; return ( - Boolean(question) && ( - + parseRecallInformation(element, selectedLanguage, responseData, currentVariables) + ), + }} + value={responseData} onChange={onChange} onSubmit={onSubmit} onBack={onBack} ttc={ttc} setTtc={setTtc} onFileUpload={onFileUpload} - isFirstQuestion={question.id === questions[0]?.id} + isFirstBlock={block.id === localSurvey.blocks[0]?.id} skipPrefilled={skipPrefilled} - prefilledQuestionValue={getQuestionPrefillData(question.id, offset)} - isLastQuestion={question.id === questions[questions.length - 1].id} + prefilledResponseData={offset === 0 ? prefillResponseData : undefined} + isLastBlock={block.id === localSurvey.blocks[localSurvey.blocks.length - 1].id} languageCode={selectedLanguage} autoFocusEnabled={autoFocusEnabled} - currentQuestionId={questionId} + currentBlockId={blockId} isBackButtonHidden={localSurvey.isBackButtonHidden} onOpenExternalURL={onOpenExternalURL} dir={dir} @@ -783,7 +795,7 @@ export function Survey({
- {showProgressBar ? : null} + {showProgressBar ? : null}
); diff --git a/packages/surveys/src/components/general/welcome-card.tsx b/packages/surveys/src/components/general/welcome-card.tsx index 518cac5546..cf64979f31 100644 --- a/packages/surveys/src/components/general/welcome-card.tsx +++ b/packages/surveys/src/components/general/welcome-card.tsx @@ -7,7 +7,7 @@ import { SubmitButton } from "@/components/buttons/submit-button"; import { ScrollableContainer } from "@/components/wrappers/scrollable-container"; import { getLocalizedValue } from "@/lib/i18n"; import { replaceRecallInfo } from "@/lib/recall"; -import { calculateElementIdx, getElementsFromSurvey } from "@/lib/utils"; +import { calculateElementIdx, getElementsFromSurveyBlocks } from "@/lib/utils"; import { Headline } from "./headline"; import { Subheader } from "./subheader"; @@ -84,7 +84,7 @@ export function WelcomeCard({ const { t } = useTranslation(); const calculateTimeToComplete = () => { - const questions = getElementsFromSurvey(survey); + const questions = getElementsFromSurveyBlocks(survey.blocks); let totalCards = questions.length; if (survey.endings.length > 0) totalCards += 1; let idx = calculateElementIdx(survey, 0, totalCards); diff --git a/packages/surveys/src/components/icons/link-icon.tsx b/packages/surveys/src/components/icons/link-icon.tsx new file mode 100644 index 0000000000..8f1781a5dd --- /dev/null +++ b/packages/surveys/src/components/icons/link-icon.tsx @@ -0,0 +1,23 @@ +interface LinkIconProps { + className?: string; +} + +export const LinkIcon = ({ className }: LinkIconProps) => { + return ( + + + + + + ); +}; diff --git a/packages/surveys/src/components/questions/address-question.tsx b/packages/surveys/src/components/questions/address-question.tsx index 78cb133d6d..8e8c1b069d 100644 --- a/packages/surveys/src/components/questions/address-question.tsx +++ b/packages/surveys/src/components/questions/address-question.tsx @@ -1,57 +1,37 @@ import { useMemo, useRef, useState } from "preact/hooks"; import { useCallback } from "react"; -import { TI18nString } from "@formbricks/types/i18n"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; import type { TSurveyAddressElement } from "@formbricks/types/surveys/elements"; -import { BackButton } from "@/components/buttons/back-button"; -import { SubmitButton } from "@/components/buttons/submit-button"; import { Headline } from "@/components/general/headline"; import { Input } from "@/components/general/input"; import { Label } from "@/components/general/label"; import { QuestionMedia } from "@/components/general/question-media"; import { Subheader } from "@/components/general/subheader"; -import { ScrollableContainer } from "@/components/wrappers/scrollable-container"; import { getLocalizedValue } from "@/lib/i18n"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; interface AddressQuestionProps { question: TSurveyAddressElement; - buttonLabel?: TI18nString; - backButtonLabel?: TI18nString; value?: string[]; onChange: (responseData: TResponseData) => void; - onSubmit: (data: TResponseData, ttc: TResponseTtc) => void; - onBack: () => void; - isFirstQuestion: boolean; - isLastQuestion: boolean; languageCode: string; ttc: TResponseTtc; setTtc: (ttc: TResponseTtc) => void; currentQuestionId: string; autoFocusEnabled: boolean; - isBackButtonHidden: boolean; dir?: "ltr" | "rtl" | "auto"; - fullSizeCards: boolean; } export function AddressQuestion({ question, - buttonLabel, - backButtonLabel, value, onChange, - onSubmit, - onBack, - isFirstQuestion, - isLastQuestion, languageCode, ttc, setTtc, currentQuestionId, autoFocusEnabled, - isBackButtonHidden, dir = "auto", - fullSizeCards, }: Readonly) { const [startTime, setStartTime] = useState(performance.now()); const isMediaAvailable = question.imageUrl || question.videoUrl; @@ -110,12 +90,6 @@ export function AddressQuestion({ e.preventDefault(); const updatedTtc = getUpdatedTtc(ttc, question.id, performance.now() - startTime); setTtc(updatedTtc); - const containsAllEmptyStrings = safeValue.length === 6 && safeValue.every((item) => item.trim() === ""); - if (containsAllEmptyStrings) { - onSubmit({ [question.id]: [] }, updatedTtc); - } else { - onSubmit({ [question.id]: safeValue }, updatedTtc); - } }; const addressRef = useCallback( @@ -129,86 +103,61 @@ export function AddressQuestion({ ); return ( - -
-
- {isMediaAvailable ? ( - - ) : null} - - + +
+ {isMediaAvailable ? : null} + + -
- {fields.map((field, index) => { - const isFieldRequired = () => { - if (field.required) { - return true; - } +
+ {fields.map((field, index) => { + const isFieldRequired = () => { + if (field.required) { + return true; + } - // if all fields are optional and the question is required, then the fields should be required - if ( - fields.filter((currField) => currField.show).every((currField) => !currField.required) && - question.required - ) { - return true; - } + // if all fields are optional and the question is required, then the fields should be required + if ( + fields.filter((currField) => currField.show).every((currField) => !currField.required) && + question.required + ) { + return true; + } - return false; - }; + return false; + }; - return ( - field.show && ( -
-
- ) - ); - })} -
-
- -
- {!isFirstQuestion && !isBackButtonHidden && ( - { - const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime); - setTtc(updatedttc); - onBack(); - }} - /> - )} -
+ return ( + field.show && ( +
+
+ ) + ); + })}
- - +
+ ); } diff --git a/packages/surveys/src/components/questions/cal-question.tsx b/packages/surveys/src/components/questions/cal-question.tsx index fa927af3ab..f4c5409863 100644 --- a/packages/surveys/src/components/questions/cal-question.tsx +++ b/packages/surveys/src/components/questions/cal-question.tsx @@ -1,130 +1,75 @@ -import { useCallback, useRef, useState } from "preact/hooks"; +import { useCallback, useState } from "preact/hooks"; import { useTranslation } from "react-i18next"; -import { TI18nString } from "@formbricks/types/i18n"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; import type { TSurveyCalElement } from "@formbricks/types/surveys/elements"; -import { BackButton } from "@/components/buttons/back-button"; -import { SubmitButton } from "@/components/buttons/submit-button"; import { CalEmbed } from "@/components/general/cal-embed"; import { Headline } from "@/components/general/headline"; import { QuestionMedia } from "@/components/general/question-media"; import { Subheader } from "@/components/general/subheader"; -import { - ScrollableContainer, - type ScrollableContainerHandle, -} from "@/components/wrappers/scrollable-container"; import { getLocalizedValue } from "@/lib/i18n"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; interface CalQuestionProps { question: TSurveyCalElement; - buttonLabel?: TI18nString; - backButtonLabel?: TI18nString; value: string; onChange: (responseData: TResponseData) => void; - onSubmit: (data: TResponseData, ttc: TResponseTtc) => void; - onBack: () => void; - isFirstQuestion: boolean; - isLastQuestion: boolean; languageCode: string; ttc: TResponseTtc; setTtc: (ttc: TResponseTtc) => void; - autoFocusEnabled: boolean; currentQuestionId: string; - isBackButtonHidden: boolean; - fullSizeCards: boolean; } export function CalQuestion({ question, - buttonLabel, - backButtonLabel, value, onChange, - onSubmit, - onBack, - isFirstQuestion, - isLastQuestion, languageCode, ttc, setTtc, currentQuestionId, - isBackButtonHidden, - fullSizeCards, }: Readonly) { const { t } = useTranslation(); const [startTime, setStartTime] = useState(performance.now()); const isMediaAvailable = question.imageUrl || question.videoUrl; const [errorMessage, setErrorMessage] = useState(""); - const scrollableRef = useRef(null); useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId); - const isCurrent = question.id === currentQuestionId; + const onSuccessfulBooking = useCallback(() => { onChange({ [question.id]: "booked" }); const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime); setTtc(updatedttc); - onSubmit({ [question.id]: "booked" }, updatedttc); - }, [onChange, onSubmit, question.id, setTtc, startTime, ttc]); + }, [onChange, question.id, setTtc, startTime, ttc]); return ( - -
{ - e.preventDefault(); - if (question.required && !value) { - setErrorMessage(t("errors.please_book_an_appointment")); - // Scroll to bottom to show the error message - setTimeout(() => { - if (scrollableRef.current?.scrollToBottom) { - scrollableRef.current.scrollToBottom(); - } - }, 100); - return; - } + { + e.preventDefault(); + if (question.required && !value) { + setErrorMessage(t("errors.please_book_an_appointment")); + return; + } - const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime); - setTtc(updatedttc); + const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime); + setTtc(updatedttc); - onChange({ [question.id]: value }); - onSubmit({ [question.id]: value }, updatedttc); - }} - className="fb-w-full"> -
- {isMediaAvailable ? ( - - ) : null} - - - - {errorMessage ? {errorMessage} : null} -
-
- - -
- {!isFirstQuestion && !isBackButtonHidden && ( - { - onBack(); - }} - tabIndex={isCurrent ? 0 : -1} - /> - )} -
- - + onChange({ [question.id]: value }); + }} + className="fb-w-full"> +
+ {isMediaAvailable ? : null} + + + + {errorMessage ? {errorMessage} : null} +
+ ); } diff --git a/packages/surveys/src/components/questions/consent-question.tsx b/packages/surveys/src/components/questions/consent-question.tsx index e51273aacd..5dd6426128 100644 --- a/packages/surveys/src/components/questions/consent-question.tsx +++ b/packages/surveys/src/components/questions/consent-question.tsx @@ -1,54 +1,34 @@ import { useCallback, useState } from "preact/hooks"; -import { TI18nString } from "@formbricks/types/i18n"; import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses"; import type { TSurveyConsentElement } from "@formbricks/types/surveys/elements"; -import { BackButton } from "@/components/buttons/back-button"; -import { SubmitButton } from "@/components/buttons/submit-button"; import { Headline } from "@/components/general/headline"; import { QuestionMedia } from "@/components/general/question-media"; import { Subheader } from "@/components/general/subheader"; -import { ScrollableContainer } from "@/components/wrappers/scrollable-container"; import { getLocalizedValue } from "@/lib/i18n"; import { getUpdatedTtc, useTtc } from "@/lib/ttc"; interface ConsentQuestionProps { question: TSurveyConsentElement; - buttonLabel?: TI18nString; - backButtonLabel?: TI18nString; value: string; onChange: (responseData: TResponseData) => void; - onSubmit: (data: TResponseData, ttc: TResponseTtc) => void; - onBack: () => void; - isFirstQuestion: boolean; - isLastQuestion: boolean; languageCode: string; ttc: TResponseTtc; setTtc: (ttc: TResponseTtc) => void; autoFocusEnabled: boolean; currentQuestionId: string; - isBackButtonHidden: boolean; dir?: "ltr" | "rtl" | "auto"; - fullSizeCards: boolean; } export function ConsentQuestion({ question, - buttonLabel, - backButtonLabel, value, onChange, - onSubmit, - onBack, - isFirstQuestion, - isLastQuestion, languageCode, ttc, setTtc, currentQuestionId, autoFocusEnabled, - isBackButtonHidden, dir = "auto", - fullSizeCards, }: Readonly) { const [startTime, setStartTime] = useState(performance.now()); const isMediaAvailable = question.imageUrl || question.videoUrl; @@ -67,81 +47,59 @@ export function ConsentQuestion({ ); return ( - -
{ - e.preventDefault(); - const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime); - setTtc(updatedTtcObj); - onSubmit({ [question.id]: value }, updatedTtcObj); - }}> - {isMediaAvailable ? : null} - - -