diff --git a/apps/web/lib/survey/service.ts b/apps/web/lib/survey/service.ts index dd90918e6b..e76287750c 100644 --- a/apps/web/lib/survey/service.ts +++ b/apps/web/lib/survey/service.ts @@ -15,7 +15,13 @@ import { getActionClasses } from "../actionClass/service"; import { ITEMS_PER_PAGE } from "../constants"; import { capturePosthogEnvironmentEvent } from "../posthogServer"; import { validateInputs } from "../utils/validate"; -import { checkForInvalidImagesInQuestions, transformPrismaSurvey } from "./utils"; +import { + checkForInvalidImagesInQuestions, + checkForInvalidMediaInBlocks, + stripIsDraftFromBlocks, + transformPrismaSurvey, + validateMediaAndPrepareBlocks, +} from "./utils"; interface TriggerUpdate { create?: Array<{ actionClassId: string }>; @@ -298,6 +304,14 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise => checkForInvalidImagesInQuestions(questions); + // Add blocks media validation + if (updatedSurvey.blocks && updatedSurvey.blocks.length > 0) { + const blocksValidation = checkForInvalidMediaInBlocks(updatedSurvey.blocks); + if (!blocksValidation.ok) { + throw new InvalidInputError(blocksValidation.error.message); + } + } + if (languages) { // Process languages update logic here // Extract currentLanguageIds and updatedLanguageIds @@ -505,6 +519,11 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise => return rest; }); + // Strip isDraft from elements before saving + if (updatedSurvey.blocks && updatedSurvey.blocks.length > 0) { + data.blocks = stripIsDraftFromBlocks(updatedSurvey.blocks); + } + const organization = await getOrganizationByEnvironmentId(environmentId); if (!organization) { throw new ResourceNotFoundError("Organization", null); @@ -609,6 +628,11 @@ export const createSurvey = async ( checkForInvalidImagesInQuestions(data.questions); } + // Validate and prepare blocks for persistence + if (data.blocks && data.blocks.length > 0) { + data.blocks = validateMediaAndPrepareBlocks(data.blocks); + } + const survey = await prisma.survey.create({ data: { ...data, @@ -623,14 +647,6 @@ export const createSurvey = async ( // if the survey created is an "app" survey, we also create a private segment for it. if (survey.type === "app") { - // const newSegment = await createSegment({ - // environmentId: parsedEnvironmentId, - // surveyId: survey.id, - // filters: [], - // title: survey.id, - // isPrivate: true, - // }); - const newSegment = await prisma.segment.create({ data: { title: survey.id, diff --git a/apps/web/lib/survey/utils.test.ts b/apps/web/lib/survey/utils.test.ts index fd2426cd98..1b0ab31a3c 100644 --- a/apps/web/lib/survey/utils.test.ts +++ b/apps/web/lib/survey/utils.test.ts @@ -2,9 +2,17 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { InvalidInputError } from "@formbricks/types/errors"; import { TJsEnvironmentStateSurvey } from "@formbricks/types/js"; import { TSegment } from "@formbricks/types/segment"; +import { TSurveyBlock } from "@formbricks/types/surveys/blocks"; +import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; +import * as videoValidation from "@/lib/utils/video-upload"; import * as fileValidation from "@/modules/storage/utils"; -import { anySurveyHasFilters, checkForInvalidImagesInQuestions, transformPrismaSurvey } from "./utils"; +import { + anySurveyHasFilters, + checkForInvalidImagesInQuestions, + checkForInvalidMediaInBlocks, + transformPrismaSurvey, +} from "./utils"; describe("transformPrismaSurvey", () => { test("transforms prisma survey without segment", () => { @@ -252,3 +260,418 @@ describe("checkForInvalidImagesInQuestions", () => { expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(3, "image3.jpg"); }); }); + +describe("checkForInvalidMediaInBlocks", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + test("returns ok when blocks array is empty", () => { + const blocks: TSurveyBlock[] = []; + + const result = checkForInvalidMediaInBlocks(blocks); + + expect(result.ok).toBe(true); + }); + + test("returns ok when blocks have no images", () => { + const blocks: TSurveyBlock[] = [ + { + id: "block-1", + name: "Block 1", + elements: [ + { + id: "elem-1", + type: TSurveyElementTypeEnum.OpenText, + headline: { default: "Question" }, + required: false, + inputType: "text", + } as unknown as TSurveyElement, + ], + }, + ]; + + const result = checkForInvalidMediaInBlocks(blocks); + + expect(result.ok).toBe(true); + }); + + test("returns ok when all element images are valid", () => { + vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true); + + const blocks: TSurveyBlock[] = [ + { + id: "block-1", + name: "Block 1", + elements: [ + { + id: "elem-1", + type: TSurveyElementTypeEnum.PictureSelection, + headline: { default: "Question" }, + required: false, + choices: [ + { id: "c1", label: { default: "Option 1" }, imageUrl: "image1.jpg" }, + { id: "c2", label: { default: "Option 2" }, imageUrl: "image2.jpg" }, + ], + } as unknown as TSurveyElement, + ], + }, + ]; + + const result = checkForInvalidMediaInBlocks(blocks); + + expect(result.ok).toBe(true); + expect(fileValidation.isValidImageFile).toHaveBeenCalledWith("image1.jpg"); + expect(fileValidation.isValidImageFile).toHaveBeenCalledWith("image2.jpg"); + }); + + test("returns error when element image is invalid", () => { + vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(false); + + const blocks: TSurveyBlock[] = [ + { + id: "block-1", + name: "Welcome Block", + elements: [ + { + id: "welcome", + type: TSurveyElementTypeEnum.PictureSelection, + headline: { default: "Welcome" }, + required: false, + choices: [ + { id: "c1", label: { default: "Option 1" }, imageUrl: "image1.jpg" }, + { id: "c2", label: { default: "Option 2" }, imageUrl: "image2.jpg" }, + ], + } as unknown as TSurveyElement, + ], + }, + ]; + + const result = checkForInvalidMediaInBlocks(blocks); + + expect(result.ok).toBe(false); + if (!result.ok) { + console.log(result.error); + expect(result.error.message).toBe( + 'Invalid image URL in choice 1 of question 1 of block "Welcome Block"' + ); + } + expect(fileValidation.isValidImageFile).toHaveBeenCalledWith("image1.jpg"); + }); + + test("returns ok when all choice images are valid", () => { + vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true); + + const blocks: TSurveyBlock[] = [ + { + id: "block-1", + name: "Choice Block", + elements: [ + { + id: "choice-q", + type: TSurveyElementTypeEnum.PictureSelection, + headline: { default: "Pick one" }, + required: true, + choices: [ + { id: "c1", imageUrl: "image1.jpg" }, + { id: "c2", imageUrl: "image2.jpg" }, + ], + } as unknown as TSurveyElement, + ], + }, + ]; + + const result = checkForInvalidMediaInBlocks(blocks); + + expect(result.ok).toBe(true); + expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(2); + expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(1, "image1.jpg"); + expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(2, "image2.jpg"); + }); + + test("returns error when choice image is invalid", () => { + vi.spyOn(fileValidation, "isValidImageFile").mockImplementation((url) => url === "valid.jpg"); + + const blocks: TSurveyBlock[] = [ + { + id: "block-1", + name: "Picture Selection", + elements: [ + { + id: "pic-select", + type: TSurveyElementTypeEnum.PictureSelection, + headline: { default: "Select a picture" }, + required: true, + choices: [ + { id: "c1", imageUrl: "valid.jpg" }, + { id: "c2", imageUrl: "invalid.txt" }, + ], + } as unknown as TSurveyElement, + ], + }, + ]; + + const result = checkForInvalidMediaInBlocks(blocks); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe( + 'Invalid image URL in choice 2 of question 1 of block "Picture Selection"' + ); + } + }); + + test("returns ok when video URL is valid (YouTube)", () => { + vi.spyOn(videoValidation, "isValidVideoUrl").mockReturnValue(true); + + const blocks: TSurveyBlock[] = [ + { + id: "block-1", + name: "Video Block", + elements: [ + { + id: "video-q", + type: TSurveyElementTypeEnum.CTA, + headline: { default: "Watch this" }, + required: false, + videoUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + } as unknown as TSurveyElement, + ], + }, + ]; + + const result = checkForInvalidMediaInBlocks(blocks); + + expect(result.ok).toBe(true); + expect(videoValidation.isValidVideoUrl).toHaveBeenCalledWith( + "https://www.youtube.com/watch?v=dQw4w9WgXcQ" + ); + }); + + test("returns error when video URL is invalid (not YouTube/Vimeo/Loom)", () => { + vi.spyOn(videoValidation, "isValidVideoUrl").mockReturnValue(false); + + const blocks: TSurveyBlock[] = [ + { + id: "block-1", + name: "Video Block", + elements: [ + { + id: "video-q", + type: TSurveyElementTypeEnum.CTA, + headline: { default: "Watch this" }, + required: false, + videoUrl: "https://example.com/video.mp4", + } as unknown as TSurveyElement, + ], + }, + ]; + + const result = checkForInvalidMediaInBlocks(blocks); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain("Invalid video URL"); + expect(result.error.message).toContain("question 1"); + expect(result.error.message).toContain("YouTube, Vimeo, and Loom"); + } + }); + + test("validates images across multiple blocks", () => { + vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true); + + const blocks: TSurveyBlock[] = [ + { + id: "block-1", + name: "Block 1", + elements: [ + { + id: "elem-1", + type: TSurveyElementTypeEnum.OpenText, + headline: { default: "Q1" }, + required: false, + inputType: "text", + imageUrl: "image1.jpg", + } as unknown as TSurveyElement, + ], + }, + { + id: "block-2", + name: "Block 2", + elements: [ + { + id: "elem-2", + type: TSurveyElementTypeEnum.Rating, + headline: { default: "Q2" }, + required: true, + range: 5, + scale: "star", + imageUrl: "image2.jpg", + } as unknown as TSurveyElement, + ], + }, + ]; + + const result = checkForInvalidMediaInBlocks(blocks); + + expect(result.ok).toBe(true); + expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(2); + expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(1, "image1.jpg"); + expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(2, "image2.jpg"); + }); + + test("stops at first invalid image and returns specific error", () => { + vi.spyOn(fileValidation, "isValidImageFile").mockImplementation((url) => url !== "bad-image.gif"); + + const blocks: TSurveyBlock[] = [ + { + id: "block-1", + name: "Block 1", + elements: [ + { + id: "elem-1", + type: TSurveyElementTypeEnum.OpenText, + headline: { default: "Q1" }, + required: false, + inputType: "text", + imageUrl: "good.jpg", + } as unknown as TSurveyElement, + ], + }, + { + id: "block-2", + name: "Block 2", + elements: [ + { + id: "elem-2", + type: TSurveyElementTypeEnum.CTA, + headline: { default: "Q2" }, + required: false, + imageUrl: "bad-image.gif", + } as unknown as TSurveyElement, + { + id: "elem-3", + type: TSurveyElementTypeEnum.OpenText, + headline: { default: "Q3" }, + required: false, + inputType: "text", + imageUrl: "another.jpg", + } as unknown as TSurveyElement, + ], + }, + ]; + + const result = checkForInvalidMediaInBlocks(blocks); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe('Invalid image URL in question 1 of block "Block 2" (block 2)'); + } + // Should stop after finding first invalid image + expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(2); + }); + + test("validates choices without imageUrl (skips gracefully)", () => { + vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true); + + const blocks: TSurveyBlock[] = [ + { + id: "block-1", + name: "Choice Block", + elements: [ + { + id: "mc-q", + type: TSurveyElementTypeEnum.PictureSelection, + headline: { default: "Pick one" }, + required: true, + choices: [{ id: "c1", imageUrl: "image.jpg" }], + } as unknown as TSurveyElement, + ], + }, + ]; + + const result = checkForInvalidMediaInBlocks(blocks); + + expect(result.ok).toBe(true); + // Only validates the one with imageUrl + expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(1); + expect(fileValidation.isValidImageFile).toHaveBeenCalledWith("image.jpg"); + }); + + test("handles multiple elements in single block", () => { + vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true); + + const blocks: TSurveyBlock[] = [ + { + id: "block-1", + name: "Multi-Element Block", + elements: [ + { + id: "elem-1", + type: TSurveyElementTypeEnum.OpenText, + headline: { default: "Q1" }, + required: false, + inputType: "text", + imageUrl: "img1.jpg", + } as unknown as TSurveyElement, + { + id: "elem-2", + type: TSurveyElementTypeEnum.Rating, + headline: { default: "Q2" }, + required: true, + range: 5, + scale: "number", + imageUrl: "img2.jpg", + } as unknown as TSurveyElement, + { + id: "elem-3", + type: TSurveyElementTypeEnum.CTA, + headline: { default: "Q3" }, + required: false, + imageUrl: "img3.jpg", + } as unknown as TSurveyElement, + ], + }, + ]; + + const result = checkForInvalidMediaInBlocks(blocks); + + expect(result.ok).toBe(true); + expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(3); + expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(1, "img1.jpg"); + expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(2, "img2.jpg"); + expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(3, "img3.jpg"); + }); + + test("validates both element imageUrl and choice imageUrls", () => { + vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true); + + const blocks: TSurveyBlock[] = [ + { + id: "block-1", + name: "Complex Block", + elements: [ + { + id: "elem-1", + type: TSurveyElementTypeEnum.PictureSelection, + headline: { default: "Choose" }, + required: true, + imageUrl: "element-image.jpg", + choices: [ + { id: "c1", imageUrl: "choice1.jpg" }, + { id: "c2", imageUrl: "choice2.jpg" }, + ], + } as unknown as TSurveyElement, + ], + }, + ]; + + const result = checkForInvalidMediaInBlocks(blocks); + + expect(result.ok).toBe(true); + expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(3); + expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(1, "element-image.jpg"); + expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(2, "choice1.jpg"); + expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(3, "choice2.jpg"); + }); +}); diff --git a/apps/web/lib/survey/utils.ts b/apps/web/lib/survey/utils.ts index 5901b5e054..8f9eed47d0 100644 --- a/apps/web/lib/survey/utils.ts +++ b/apps/web/lib/survey/utils.ts @@ -1,8 +1,16 @@ import "server-only"; +import { Result, err, ok } from "@formbricks/types/error-handlers"; import { InvalidInputError } from "@formbricks/types/errors"; import { TJsEnvironmentStateSurvey } from "@formbricks/types/js"; import { TSegment } from "@formbricks/types/segment"; +import { TSurveyBlock } from "@formbricks/types/surveys/blocks"; +import { + TSurveyElement, + TSurveyElementTypeEnum, + TSurveyPictureChoice, +} from "@formbricks/types/surveys/elements"; import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; +import { isValidVideoUrl } from "@/lib/utils/video-upload"; import { isValidImageFile } from "@/modules/storage/utils"; export const transformPrismaSurvey = ( @@ -56,3 +64,154 @@ export const checkForInvalidImagesInQuestions = (questions: TSurveyQuestion[]) = } }); }; + +/** + * Validates a single choice's image URL + * @param choice - Choice to validate + * @param choiceIdx - Index of the choice for error reporting + * @param questionIdx - Index of the question for error reporting + * @param blockName - Block name for error reporting + * @returns Result with void data on success or Error on failure + */ +const validateChoiceImage = ( + choice: TSurveyPictureChoice, + choiceIdx: number, + questionIdx: number, + blockName: string +): Result => { + if (choice.imageUrl && !isValidImageFile(choice.imageUrl)) { + return err( + new Error( + `Invalid image URL in choice ${choiceIdx + 1} of question ${questionIdx + 1} of block "${blockName}"` + ) + ); + } + return ok(undefined); +}; + +/** + * Validates choice images for picture selection elements + * Only picture selection questions have imageUrl in choices + * @param element - Element with choices to validate + * @param questionIdx - Index of the question for error reporting + * @param blockName - Block name for error reporting + * @returns Result with void data on success or Error on failure + */ +const validatePictureSelectionChoiceImages = ( + element: TSurveyElement, + questionIdx: number, + blockName: string +): Result => { + // Only validate choices for picture selection questions + if (element.type !== TSurveyElementTypeEnum.PictureSelection) { + return ok(undefined); + } + + if (!("choices" in element) || !Array.isArray(element.choices)) { + return ok(undefined); + } + + for (let choiceIdx = 0; choiceIdx < element.choices.length; choiceIdx++) { + const result = validateChoiceImage(element.choices[choiceIdx], choiceIdx, questionIdx, blockName); + if (!result.ok) { + return result; + } + } + + return ok(undefined); +}; + +/** + * Validates a single element's image URL, video URL, and picture selection choice images + * @param element - Element to validate + * @param elementIdx - Index of the element for error reporting + * @param blockIdx - Index of the block for error reporting + * @param blockName - Block name for error reporting + * @returns Result with void data on success or Error on failure + */ +const validateElement = ( + element: TSurveyElement, + elementIdx: number, + blockIdx: number, + blockName: string +): Result => { + // Check element imageUrl + if (element.imageUrl && !isValidImageFile(element.imageUrl)) { + return err( + new Error( + `Invalid image URL in question ${elementIdx + 1} of block "${blockName}" (block ${blockIdx + 1})` + ) + ); + } + + // Check element videoUrl + if (element.videoUrl && !isValidVideoUrl(element.videoUrl)) { + return err( + new Error( + `Invalid video URL in question ${elementIdx + 1} of block "${blockName}" (block ${blockIdx + 1}). Only YouTube, Vimeo, and Loom URLs are supported.` + ) + ); + } + + // Check choices for picture selection + return validatePictureSelectionChoiceImages(element, elementIdx, blockName); +}; + +/** + * Validates that all media URLs (images and videos) in blocks are valid + * - Validates element imageUrl + * - Validates element videoUrl + * - Validates choice imageUrl for picture selection elements + * @param blocks - Array of survey blocks to validate + * @returns Result with void data on success or Error on failure + */ +export const checkForInvalidMediaInBlocks = (blocks: TSurveyBlock[]): Result => { + for (let blockIdx = 0; blockIdx < blocks.length; blockIdx++) { + const block = blocks[blockIdx]; + + for (let elementIdx = 0; elementIdx < block.elements.length; elementIdx++) { + const result = validateElement(block.elements[elementIdx], elementIdx, blockIdx, block.name); + if (!result.ok) { + return result; + } + } + } + + return ok(undefined); +}; + +/** + * Strips isDraft field from elements before saving to database + * Note: Blocks don't have isDraft since block IDs are CUIDs (not user-editable) + * Only element IDs need protection as they're user-editable and used in responses + * @param blocks - Array of survey blocks + * @returns New array with isDraft stripped from all elements + */ +export const stripIsDraftFromBlocks = (blocks: TSurveyBlock[]): TSurveyBlock[] => { + return blocks.map((block) => ({ + ...block, + elements: block.elements.map((element) => { + const { isDraft, ...elementRest } = element; + return elementRest; + }), + })); +}; + +/** + * Validates and prepares blocks for persistence + * - Validates all media URLs (images and videos) in blocks + * - Strips isDraft flags from elements + * @param blocks - Array of survey blocks to validate and prepare + * @returns Prepared blocks ready for database persistence + * @throws Error if any media validation fails + */ +export const validateMediaAndPrepareBlocks = (blocks: TSurveyBlock[]): TSurveyBlock[] => { + // Validate media (images and videos) + const validation = checkForInvalidMediaInBlocks(blocks); + if (!validation.ok) { + throw validation.error; + } + + // Strip isDraft + return stripIsDraftFromBlocks(blocks); +}; diff --git a/apps/web/lib/utils/video-upload.ts b/apps/web/lib/utils/video-upload.ts index 74ddddfc03..dc301f7ec2 100644 --- a/apps/web/lib/utils/video-upload.ts +++ b/apps/web/lib/utils/video-upload.ts @@ -124,3 +124,12 @@ export const convertToEmbedUrl = (url: string): string | undefined => { // If no supported platform found, return undefined return undefined; }; + +/** + * Validates if a URL is from a supported video platform (YouTube, Vimeo, or Loom) + * @param url - URL to validate + * @returns true if URL is from a supported platform, false otherwise + */ +export const isValidVideoUrl = (url: string): boolean => { + return checkForYoutubeUrl(url) || checkForVimeoUrl(url) || checkForLoomUrl(url); +}; diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 077878cd99..acce49db09 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -1619,6 +1619,7 @@ "unlock_targeting_title": "Targeting mit einem höheren Plan freischalten", "unsaved_changes_warning": "Du hast ungespeicherte Änderungen in deiner Umfrage. Möchtest Du sie speichern, bevor Du gehst?", "until_they_submit_a_response": "Bis sie eine Antwort einreichen", + "untitled_block": "Unbenannter Block", "upgrade_notice_description": "Erstelle mehrsprachige Umfragen und entdecke viele weitere Funktionen", "upgrade_notice_title": "Schalte mehrsprachige Umfragen mit einem höheren Plan frei", "upload": "Hochladen", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index 3a6cf7bfe0..d337759bf0 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -1619,6 +1619,7 @@ "unlock_targeting_title": "Unlock targeting with a higher plan", "unsaved_changes_warning": "You have unsaved changes in your survey. Would you like to save them before leaving?", "until_they_submit_a_response": "Until they submit a response", + "untitled_block": "Untitled Block", "upgrade_notice_description": "Create multilingual surveys and unlock many more features", "upgrade_notice_title": "Unlock multi-language surveys with a higher plan", "upload": "Upload", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 3f7c18ae0f..fa97edc177 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -1619,6 +1619,7 @@ "unlock_targeting_title": "Débloquez le ciblage avec un plan supérieur.", "unsaved_changes_warning": "Vous avez des modifications non enregistrées dans votre enquête. Souhaitez-vous les enregistrer avant de partir ?", "until_they_submit_a_response": "Jusqu'à ce qu'ils soumettent une réponse", + "untitled_block": "Bloc sans titre", "upgrade_notice_description": "Créez des sondages multilingues et débloquez de nombreuses autres fonctionnalités", "upgrade_notice_title": "Débloquez les sondages multilingues avec un plan supérieur", "upload": "Télécharger", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 5f70820ab1..4adc056e4e 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -1619,6 +1619,7 @@ "unlock_targeting_title": "上位プランでターゲティングをアンロック", "unsaved_changes_warning": "フォームに未保存の変更があります。離れる前に保存しますか?", "until_they_submit_a_response": "回答を送信するまで", + "untitled_block": "無題のブロック", "upgrade_notice_description": "多言語フォームを作成し、さらに多くの機能をアンロック", "upgrade_notice_title": "上位プランで多言語フォームをアンロック", "upload": "アップロード", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 286064a997..c5fc905f68 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -1619,6 +1619,7 @@ "unlock_targeting_title": "Desbloqueie o direcionamento com um plano superior", "unsaved_changes_warning": "Você tem alterações não salvas na sua pesquisa. Quer salvar antes de sair?", "until_they_submit_a_response": "Até eles enviarem uma resposta", + "untitled_block": "Bloco sem título", "upgrade_notice_description": "Crie pesquisas multilíngues e desbloqueie muitas outras funcionalidades", "upgrade_notice_title": "Desbloqueie pesquisas multilíngues com um plano superior", "upload": "Enviar", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 9b8d584a22..aefcd62fb9 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -1619,6 +1619,7 @@ "unlock_targeting_title": "Desbloqueie a segmentação com um plano superior", "unsaved_changes_warning": "Tem alterações não guardadas no seu inquérito. Gostaria de as guardar antes de sair?", "until_they_submit_a_response": "Até que enviem uma resposta", + "untitled_block": "Bloco sem título", "upgrade_notice_description": "Crie inquéritos multilingues e desbloqueie muitas mais funcionalidades", "upgrade_notice_title": "Desbloqueie inquéritos multilingues com um plano superior", "upload": "Carregar", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index b1dc2bf7a3..ecfce4a45c 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -1619,6 +1619,7 @@ "unlock_targeting_title": "Deblocați țintirea cu un plan superior", "unsaved_changes_warning": "Aveți modificări nesalvate în sondajul dumneavoastră. Doriți să le salvați înainte de a pleca?", "until_they_submit_a_response": "Până când vor furniza un răspuns", + "untitled_block": "Bloc fără titlu", "upgrade_notice_description": "Creați sondaje multilingve și deblocați multe alte caracteristici", "upgrade_notice_title": "Deblocați sondajele multilingve cu un plan superior", "upload": "Încărcați", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index 3690d05cad..a1cb87a0ee 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -1619,6 +1619,7 @@ "unlock_targeting_title": "通过 更 高级 划解锁 定位", "unsaved_changes_warning": "您在调查中有未保存的更改。离开前是否要保存?", "until_they_submit_a_response": "直到 他们 提交 回复", + "untitled_block": "未命名区块", "upgrade_notice_description": "创建 多语言 调查 并 解锁 更多 功能", "upgrade_notice_title": "解锁 更高 计划 中 的 多语言 调查", "upload": "上传", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index eae646e923..8a74808616 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -1619,6 +1619,7 @@ "unlock_targeting_title": "使用更高等級的方案解鎖目標設定", "unsaved_changes_warning": "您的問卷中有未儲存的變更。您要先儲存它們再離開嗎?", "until_they_submit_a_response": "直到他們提交回應", + "untitled_block": "未命名區塊", "upgrade_notice_description": "建立多語言問卷並解鎖更多功能", "upgrade_notice_title": "使用更高等級的方案解鎖多語言問卷", "upload": "上傳", diff --git a/apps/web/modules/survey/editor/lib/blocks.test.ts b/apps/web/modules/survey/editor/lib/blocks.test.ts new file mode 100644 index 0000000000..42a06dea70 --- /dev/null +++ b/apps/web/modules/survey/editor/lib/blocks.test.ts @@ -0,0 +1,478 @@ +import { TFunction } from "i18next"; +import { describe, expect, test } from "vitest"; +import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { + addBlock, + addElementToBlock, + deleteBlock, + deleteElementFromBlock, + duplicateBlock, + duplicateElementInBlock, + isElementIdUnique, + moveBlock, + updateBlock, + updateElementInBlock, +} from "./blocks"; + +// Mock translation function +const mockT: TFunction = ((key: string) => { + const translations: Record = { + "environments.surveys.edit.untitled_block": "Untitled Block", + }; + return translations[key] || key; +}) as TFunction; + +// 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: [], + 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, + ], + }, + ], + followUps: [], + 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); + }); + + test("should return false for duplicate element ID", () => { + const survey = createMockSurvey(); + const isUnique = isElementIdUnique("elem-1", survey.blocks); + expect(isUnique).toBe(false); + }); + }); +}); + +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: [] }); + + 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(); + } + }); + + 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"); + } + }); + }); + + describe("updateBlock", () => { + test("should update block attributes", () => { + const survey = createMockSurvey(); + const result = updateBlock(survey, "block-1", { name: "Updated Block 1" }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[0].name).toBe("Updated Block 1"); + } + }); + + 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"); + } + }); + }); + + describe("deleteBlock", () => { + test("should delete a block", () => { + const survey = createMockSurvey(); + const result = deleteBlock(survey, "block-1"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks.length).toBe(1); + expect(result.data.blocks[0].id).toBe("block-2"); + } + }); + + test("should return error for non-existent block", () => { + const survey = createMockSurvey(); + const result = deleteBlock(survey, "non-existent"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain("not found"); + } + }); + }); + + describe("duplicateBlock", () => { + test("should duplicate a block with new IDs", () => { + const survey = createMockSurvey(); + const result = duplicateBlock(survey, "block-1"); + + 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"); + } + }); + + test("should clear logic on duplicated block", () => { + const survey = createMockSurvey(); + survey.blocks[0].logic = [ + { + id: "logic-1", + conditions: { connector: "and", conditions: [] }, + actions: [], + }, + ] as any; + + const result = duplicateBlock(survey, "block-1"); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[1].logic).toBeUndefined(); + } + }); + }); + + describe("moveBlock", () => { + test("should move block down", () => { + const survey = createMockSurvey(); + const result = moveBlock(survey, "block-1", "down"); + + 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 move block up", () => { + const survey = createMockSurvey(); + const result = moveBlock(survey, "block-2", "up"); + + 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"); + } + }); + }); +}); + +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; + + 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"); + } + }); + }); + + describe("updateElementInBlock", () => { + test("should update element attributes", () => { + const survey = createMockSurvey(); + const result = updateElementInBlock(survey, "block-1", "elem-1", { + headline: { default: "Updated Question" }, + }); + + 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"); + } + }); + }); + + describe("deleteElementFromBlock", () => { + test("should delete element from block", () => { + const survey = createMockSurvey(); + const result = deleteElementFromBlock(survey, "block-1", "elem-2"); + + 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"); + } + }); + }); + + describe("duplicateElementInBlock", () => { + test("should duplicate element with new ID", () => { + const survey = createMockSurvey(); + const result = duplicateElementInBlock(survey, "block-1", "elem-1"); + + 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"); + } + }); + + test("should return error for non-existent element", () => { + const survey = createMockSurvey(); + const result = duplicateElementInBlock(survey, "block-1", "non-existent"); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain("not found"); + } + }); + }); +}); diff --git a/apps/web/modules/survey/editor/lib/blocks.ts b/apps/web/modules/survey/editor/lib/blocks.ts new file mode 100644 index 0000000000..8470366790 --- /dev/null +++ b/apps/web/modules/survey/editor/lib/blocks.ts @@ -0,0 +1,390 @@ +import { createId } from "@paralleldrive/cuid2"; +import { TFunction } from "i18next"; +import { Result, err, ok } from "@formbricks/types/error-handlers"; +import { TSurveyBlock } from "@formbricks/types/surveys/blocks"; +import { TSurveyElement } from "@formbricks/types/surveys/elements"; +import { TSurvey } from "@formbricks/types/surveys/types"; + +// ============================================ +// UTILITY FUNCTIONS +// ============================================ + +/** + * Checks if an element ID is unique across all blocks + * @param elementId - The element ID to check + * @param blocks - Array of all blocks in the survey + * @returns true if the element ID is unique, false otherwise + */ +export const isElementIdUnique = (elementId: string, blocks: TSurveyBlock[]): boolean => { + for (const block of blocks) { + if (block.elements.some((e) => e.id === elementId)) { + return false; + } + } + return true; +}; + +// ============================================ +// BLOCK OPERATIONS +// ============================================ + +/** + * Adds a new block to the survey. Always generates a new CUID for the block ID to prevent conflicts + * @param survey - The survey to add the block to + * @param block - Block data (without id, which is auto-generated) + * @param index - Optional index to insert the block at (appends if not provided) + * @returns Result with updated survey or Error + */ +export const addBlock = ( + t: TFunction, + survey: TSurvey, + block: Omit, "id">, + index?: number +): Result => { + const updatedSurvey = { ...survey }; + const blocks = [...(survey.blocks || [])]; + + const newBlock: TSurveyBlock = { + ...block, + id: createId(), + name: block.name || t("environments.surveys.edit.untitled_block"), + elements: block.elements || [], + }; + + if (index === undefined) { + blocks.push(newBlock); + } else { + if (index < 0 || index > blocks.length) { + return err(new Error(`Invalid index ${index}. Must be between 0 and ${blocks.length}`)); + } + + blocks.splice(index, 0, newBlock); + } + + updatedSurvey.blocks = blocks; + return ok(updatedSurvey); +}; + +/** + * Updates an existing block with new attributes + * @param survey - The survey containing the block + * @param blockId - The CUID of the block to update + * @param updatedAttributes - Partial block object with fields to update + * @returns Result with updated survey or Error + */ +export const updateBlock = ( + survey: TSurvey, + blockId: string, + updatedAttributes: Omit, "id"> +): Result => { + // id is not allowed from the types but this will also prevent the error during runtime + if ("id" in updatedAttributes) { + return err(new Error("Block ID cannot be updated")); + } + + 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`)); + } + + blocks[blockIndex] = { + ...blocks[blockIndex], + ...updatedAttributes, + }; + + return ok({ + ...survey, + blocks, + }); +}; + +/** + * Deletes a block from the survey + * @param survey - The survey containing the block + * @param blockId - The CUID of the block to delete + * @returns Result with updated survey or Error + */ +export const deleteBlock = (survey: TSurvey, blockId: string): Result => { + const filteredBlocks = survey.blocks?.filter((b) => b.id !== blockId) || []; + + if (filteredBlocks.length === survey.blocks?.length) { + return err(new Error(`Block with ID "${blockId}" not found`)); + } + + return ok({ + ...survey, + blocks: filteredBlocks, + }); +}; + +/** + * Duplicates a block with new IDs for the block and all elements + * Note: Logic is cleared because it would reference old block/element IDs. + * mapping old element IDs to new ones and updating jumpToBlock targets. + * @param survey - The survey containing the block + * @param blockId - The CUID of the block to duplicate + * @returns Result with updated survey or Error + */ +export const duplicateBlock = (survey: TSurvey, blockId: string): 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 blockToDuplicate = blocks[blockIndex]; + + // Deep clone the block to avoid any reference issues + const duplicatedBlock: TSurveyBlock = structuredClone(blockToDuplicate); + + // Assign new IDs + duplicatedBlock.id = createId(); + duplicatedBlock.name = `${blockToDuplicate.name} (copy)`; + + // Generate new element IDs to avoid conflicts + duplicatedBlock.elements = duplicatedBlock.elements.map((element) => ({ + ...element, + id: createId(), + isDraft: true, + })); + + // Clear logic since it references old block/element IDs + // In the future, we could map these references to the new IDs + duplicatedBlock.logic = undefined; + duplicatedBlock.logicFallback = undefined; + + const updatedBlocks = [...blocks]; + updatedBlocks.splice(blockIndex + 1, 0, duplicatedBlock); + + return ok({ + ...survey, + blocks: updatedBlocks, + }); +}; + +/** + * Moves a block up or down in the survey + * @param survey - The survey containing the block + * @param blockId - The CUID of the block to move + * @param direction - Direction to move ("up" or "down") + * @returns Result with updated survey (or unchanged if at boundary) or Error + */ +export const moveBlock = ( + survey: TSurvey, + blockId: 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`)); + } + + if (direction === "up" && blockIndex === 0) { + return ok(survey); // Already at top + } + + if (direction === "down" && blockIndex === blocks.length - 1) { + return ok(survey); // Already at bottom + } + + const targetIndex = direction === "up" ? blockIndex - 1 : blockIndex + 1; + + // Swap using destructuring assignment + [blocks[blockIndex], blocks[targetIndex]] = [blocks[targetIndex], blocks[blockIndex]]; + + return ok({ + ...survey, + blocks, + }); +}; + +// ============================================ +// ELEMENT OPERATIONS +// ============================================ + +/** + * Adds an element to a block + * Validates that the element ID is unique across all blocks + * Sets isDraft: true on the element for ID editability + * @param survey - The survey containing the block + * @param blockId - The CUID of the block to add the element to + * @param element - The element to add + * @param index - Optional index to insert the element at (appends if not provided) + * @returns Result with updated survey or Error + */ +export const addElementToBlock = ( + survey: TSurvey, + blockId: string, + element: TSurveyElement, + index?: number +): 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`)); + } + + // Validate element ID is unique across all blocks (including the target block) + if (!isElementIdUnique(element.id, blocks)) { + return err(new Error(`Element ID "${element.id}" already exists`)); + } + + const block = { ...blocks[blockIndex] }; + const elements = [...block.elements]; + + const elementWithDraft = { ...element, isDraft: true }; + + if (index === undefined) { + elements.push(elementWithDraft); + } else { + if (index < 0 || index > elements.length) { + return err(new Error(`Invalid index ${index}. Must be between 0 and ${elements.length}`)); + } + elements.splice(index, 0, elementWithDraft); + } + + block.elements = elements; + blocks[blockIndex] = block; + + return ok({ + ...survey, + blocks, + }); +}; + +/** + * Updates an existing element in 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 update + * @param updatedAttributes - Partial element object with fields to update + * @returns Result with updated survey or Error + */ +export const updateElementInBlock = ( + survey: TSurvey, + blockId: string, + elementId: string, + updatedAttributes: Partial +): 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 changing the element ID, validate the new ID is unique across all blocks + if (updatedAttributes.id && updatedAttributes.id !== elementId) { + if (!isElementIdUnique(updatedAttributes.id, blocks)) { + return err(new Error(`Element ID "${updatedAttributes.id}" already exists`)); + } + } + + elements[elementIndex] = { + ...elements[elementIndex], + ...updatedAttributes, + } as TSurveyElement; + + block.elements = elements; + blocks[blockIndex] = block; + + return ok({ + ...survey, + blocks, + }); +}; + +/** + * Deletes an element from 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 delete + * @returns Result with updated survey or Error + */ +export const deleteElementFromBlock = ( + survey: TSurvey, + blockId: string, + elementId: string +): 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 originalLength = block.elements.length; + block.elements = block.elements.filter((e) => e.id !== elementId); + + if (block.elements.length === originalLength) { + return err(new Error(`Element with ID "${elementId}" not found in block "${blockId}"`)); + } + + blocks[blockIndex] = block; + + return ok({ + ...survey, + blocks, + }); +}; + +/** + * Duplicates an element within a block + * Generates a new element ID with CUID + * Sets isDraft: true on the duplicated element + * @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 duplicate + * @returns Result with updated survey or Error + */ +export const duplicateElementInBlock = ( + survey: TSurvey, + blockId: string, + elementId: string +): 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}"`)); + } + + const elementToDuplicate = elements[elementIndex]; + + // Deep clone the element to avoid any reference issues + const duplicatedElement: TSurveyElement = structuredClone(elementToDuplicate); + duplicatedElement.id = createId(); + duplicatedElement.isDraft = true; + + elements.splice(elementIndex + 1, 0, duplicatedElement); + block.elements = elements; + blocks[blockIndex] = block; + + return ok({ + ...survey, + blocks, + }); +}; diff --git a/apps/web/modules/survey/editor/lib/survey.ts b/apps/web/modules/survey/editor/lib/survey.ts index 14a9694a17..4639914bd7 100644 --- a/apps/web/modules/survey/editor/lib/survey.ts +++ b/apps/web/modules/survey/editor/lib/survey.ts @@ -4,7 +4,7 @@ import { logger } from "@formbricks/logger"; import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; import { TSegment, ZSegmentFilters } from "@formbricks/types/segment"; import { TSurvey } from "@formbricks/types/surveys/types"; -import { checkForInvalidImagesInQuestions } from "@/lib/survey/utils"; +import { checkForInvalidImagesInQuestions, validateMediaAndPrepareBlocks } from "@/lib/survey/utils"; import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger"; import { getActionClasses } from "@/modules/survey/lib/action-class"; import { getOrganizationAIKeys, getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization"; @@ -27,6 +27,11 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise => checkForInvalidImagesInQuestions(questions); + // Validate and prepare blocks for persistence + if (updatedSurvey.blocks && updatedSurvey.blocks.length > 0) { + data.blocks = validateMediaAndPrepareBlocks(updatedSurvey.blocks); + } + if (languages) { // Process languages update logic here // Extract currentLanguageIds and updatedLanguageIds diff --git a/packages/types/js.ts b/packages/types/js.ts index bddfc7e83c..ee2c2f61ef 100644 --- a/packages/types/js.ts +++ b/packages/types/js.ts @@ -7,11 +7,6 @@ import { ZResponseHiddenFieldValue, ZResponseUpdate } from "./responses"; import { ZUploadFileConfig } from "./storage"; import { ZSurvey } from "./surveys/types"; -export const ZJsPerson = z.object({ - id: z.string().cuid2().optional(), - userId: z.string().optional(), -}); - export const ZJsEnvironmentStateSurvey = ZSurvey.innerType() .pick({ id: true, @@ -109,42 +104,6 @@ export const ZJsUserIdentifyInput = z.object({ export type TJsPersonIdentifyInput = z.infer; -export const ZJsConfig = z.object({ - environmentId: z.string().cuid(), - apiHost: z.string(), - environmentState: ZJsEnvironmentState, - personState: ZJsPersonState, - filteredSurveys: z.array(ZJsEnvironmentStateSurvey).default([]), - attributes: z.record(z.string()), - status: z.object({ - value: z.enum(["success", "error"]), - expiresAt: z.date().nullable(), - }), -}); - -export type TJsConfig = z.infer; - -export const ZJsConfigUpdateInput = ZJsConfig.omit({ status: true }).extend({ - status: z - .object({ - value: z.enum(["success", "error"]), - expiresAt: z.date().nullable(), - }) - .optional(), -}); - -export type TJsConfigUpdateInput = z.infer; - -export const ZJsConfigInput = z.object({ - environmentId: z.string().cuid2(), - apiHost: z.string(), - errorHandler: z.function().args(z.any()).returns(z.void()).optional(), - userId: z.string().optional(), - attributes: z.record(z.string()).optional(), -}); - -export type TJsConfigInput = z.infer; - export const ZJsPeopleUserIdInput = z.object({ environmentId: z.string().cuid2(), userId: z.string().min(1).max(255), @@ -154,11 +113,6 @@ export const ZJsContactsUpdateAttributeInput = z.object({ attributes: ZAttributes, }); -export const ZJsUserUpdateInput = z.object({ - userId: z.string().trim().min(1), - attributes: ZAttributes.optional(), -}); - export type TJsPeopleUpdateAttributeInput = z.infer; export type TJsPeopleUserIdInput = z.infer; diff --git a/packages/types/surveys/types.ts b/packages/types/surveys/types.ts index 2465f99fc5..e788d0d60e 100644 --- a/packages/types/surveys/types.ts +++ b/packages/types/surveys/types.ts @@ -671,7 +671,17 @@ export const ZSurvey = z }); } }), - blocks: ZSurveyBlocks.default([]), + blocks: ZSurveyBlocks.default([]).superRefine((blocks, ctx) => { + const blockIds = blocks.map((b) => b.id); + const uniqueBlockIds = new Set(blockIds); + if (uniqueBlockIds.size !== blockIds.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Block IDs must be unique", + path: [blockIds.findIndex((id, index) => blockIds.indexOf(id) !== index), "id"], + }); + } + }), endings: ZSurveyEndings.superRefine((endings, ctx) => { const endingIds = endings.map((q) => q.id); const uniqueEndingIds = new Set(endingIds);