From 49fa5c587c1f17e9f7e5edc00b4e909bbbf6ee4f Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Fri, 31 Oct 2025 17:32:47 +0530 Subject: [PATCH 1/9] feat(blocks): add editor utilities, validation, and unit tests for blocks support --- apps/web/lib/survey/service.ts | 36 +- apps/web/lib/survey/utils.ts | 63 +++ .../modules/survey/editor/lib/blocks.test.ts | 429 ++++++++++++++++++ apps/web/modules/survey/editor/lib/blocks.ts | 393 ++++++++++++++++ apps/web/modules/survey/editor/lib/survey.ts | 19 +- packages/types/js.ts | 46 -- packages/types/surveys/types.ts | 12 +- 7 files changed, 941 insertions(+), 57 deletions(-) create mode 100644 apps/web/modules/survey/editor/lib/blocks.test.ts create mode 100644 apps/web/modules/survey/editor/lib/blocks.ts diff --git a/apps/web/lib/survey/service.ts b/apps/web/lib/survey/service.ts index dd90918e6b..699704dcbc 100644 --- a/apps/web/lib/survey/service.ts +++ b/apps/web/lib/survey/service.ts @@ -15,7 +15,12 @@ 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 { + checkForInvalidImagesInBlocks, + checkForInvalidImagesInQuestions, + stripIsDraftFromBlocks, + transformPrismaSurvey, +} from "./utils"; interface TriggerUpdate { create?: Array<{ actionClassId: string }>; @@ -298,6 +303,14 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise => checkForInvalidImagesInQuestions(questions); + // Add blocks image validation + if (updatedSurvey.blocks && updatedSurvey.blocks.length > 0) { + const blocksValidation = checkForInvalidImagesInBlocks(updatedSurvey.blocks); + if (!blocksValidation.ok) { + throw blocksValidation.error; + } + } + if (languages) { // Process languages update logic here // Extract currentLanguageIds and updatedLanguageIds @@ -505,6 +518,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 +627,14 @@ export const createSurvey = async ( checkForInvalidImagesInQuestions(data.questions); } + // Add blocks validation + if (data.blocks && data.blocks.length > 0) { + const blocksValidation = checkForInvalidImagesInBlocks(data.blocks); + if (!blocksValidation.ok) { + throw blocksValidation.error; + } + } + const survey = await prisma.survey.create({ data: { ...data, @@ -623,14 +649,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.ts b/apps/web/lib/survey/utils.ts index 5901b5e054..49bce7a930 100644 --- a/apps/web/lib/survey/utils.ts +++ b/apps/web/lib/survey/utils.ts @@ -1,7 +1,9 @@ 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 { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; import { isValidImageFile } from "@/modules/storage/utils"; @@ -56,3 +58,64 @@ export const checkForInvalidImagesInQuestions = (questions: TSurveyQuestion[]) = } }); }; + +/** + * Validates that all image URLs in blocks (elements and their choices) are valid + * @param blocks - Array of survey blocks to validate + * @returns Result with void data on success or Error on failure + */ +export const checkForInvalidImagesInBlocks = (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 element = block.elements[elementIdx]; + + // Check element imageUrl + if (element.imageUrl) { + if (!isValidImageFile(element.imageUrl)) { + return err( + new Error( + `Invalid image URL in element "${element.id}" (index ${elementIdx}) of block "${block.name}" (index ${blockIdx})` + ) + ); + } + } + + // Check choices for picture selection and multiple choice elements + if ("choices" in element && Array.isArray(element.choices)) { + for (let choiceIdx = 0; choiceIdx < element.choices.length; choiceIdx++) { + const choice = element.choices[choiceIdx]; + if ("imageUrl" in choice && choice.imageUrl) { + if (!isValidImageFile(choice.imageUrl)) { + return err( + new Error( + `Invalid image URL in choice ${choiceIdx} of element "${element.id}" in block "${block.name}"` + ) + ); + } + } + } + } + } + } + + 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; + }), + })); +}; 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..1e5fa4274e --- /dev/null +++ b/apps/web/modules/survey/editor/lib/blocks.test.ts @@ -0,0 +1,429 @@ +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"; + +// 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); + }); + + test("should skip current block when provided", () => { + const survey = createMockSurvey(); + const isUnique = isElementIdUnique("elem-1", survey.blocks, "block-1"); + expect(isUnique).toBe(true); // Skips block-1 where elem-1 exists + }); + }); +}); + +describe("Block Operations", () => { + describe("addBlock", () => { + test("should add a block to the end by default", () => { + const survey = createMockSurvey(); + const result = addBlock(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(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(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(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 use custom name if provided", () => { + const survey = createMockSurvey(); + const result = duplicateBlock(survey, "block-1", { newName: "Custom Copy" }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.blocks[1].name).toBe("Custom Copy"); + } + }); + + 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 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 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..18516620a1 --- /dev/null +++ b/apps/web/modules/survey/editor/lib/blocks.ts @@ -0,0 +1,393 @@ +import { createId } from "@paralleldrive/cuid2"; +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 + * @param currentBlockId - Optional block ID to skip (for updates within same block) + * @returns true if the element ID is unique, false otherwise + */ +export const isElementIdUnique = ( + elementId: string, + blocks: TSurveyBlock[], + currentBlockId?: string +): boolean => { + for (const block of blocks) { + // Skip current block if provided (for updates within same block) + if (currentBlockId && block.id === currentBlockId) continue; + + 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 = ( + survey: TSurvey, + block: Omit, "id">, + index?: number +): Result => { + const updatedSurvey = { ...survey }; + const blocks = [...(survey.blocks || [])]; + + const newBlock: TSurveyBlock = { + id: createId(), + name: block.name || "Untitled Block", + elements: block.elements || [], + ...block, + }; + + if (index !== undefined) { + 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); + } else { + blocks.push(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: 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`)); + } + + 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 blocks = [...(survey.blocks || [])]; + const filteredBlocks = blocks.filter((b) => b.id !== blockId); + + if (filteredBlocks.length === 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. + * TODO: In the future, we could update logic references like questions do, + * 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 + * @param options - Optional configuration + * @param options.newName - Custom name for the duplicated block + * @param options.insertAfter - Whether to insert after the original (default: true) + * @returns Result with updated survey or Error + */ +export const duplicateBlock = ( + survey: TSurvey, + blockId: string, + options?: { newName?: string; insertAfter?: boolean } +): 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]; + const newBlockId = createId(); + + // Generate new element IDs to avoid conflicts + const elementsWithNewIds = blockToDuplicate.elements.map((element) => ({ + ...element, + id: createId(), + })); + + const duplicatedBlock: TSurveyBlock = { + ...blockToDuplicate, + id: newBlockId, + name: options?.newName || `${blockToDuplicate.name} (copy)`, + elements: elementsWithNewIds, + // Clear logic since it references old block/element IDs + // In the future, we could map these references to the new IDs + logic: undefined, + logicFallback: undefined, + }; + + const updatedBlocks = [...blocks]; + const insertIndex = options?.insertAfter !== false ? blockIndex + 1 : blockIndex; + updatedBlocks.splice(insertIndex, 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 + if (!isElementIdUnique(element.id, blocks, blockId)) { + return err(new Error(`Element ID "${element.id}" already exists in another block`)); + } + + const block = { ...blocks[blockIndex] }; + const elements = [...block.elements]; + + const elementWithDraft = { ...element, isDraft: true }; + + if (index !== undefined) { + 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); + } else { + elements.push(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}"`)); + } + + 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 "_copy_" suffix + * 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]; + + const duplicatedElement: TSurveyElement = { + ...elementToDuplicate, + id: createId(), + isDraft: true, + } as TSurveyElement; + + 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..3bcac4e925 100644 --- a/apps/web/modules/survey/editor/lib/survey.ts +++ b/apps/web/modules/survey/editor/lib/survey.ts @@ -4,7 +4,11 @@ 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 { + checkForInvalidImagesInBlocks, + checkForInvalidImagesInQuestions, + stripIsDraftFromBlocks, +} 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 +31,14 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise => checkForInvalidImagesInQuestions(questions); + // Add blocks validation + if (updatedSurvey.blocks && updatedSurvey.blocks.length > 0) { + const blocksValidation = checkForInvalidImagesInBlocks(updatedSurvey.blocks); + if (!blocksValidation.ok) { + throw blocksValidation.error; + } + } + if (languages) { // Process languages update logic here // Extract currentLanguageIds and updatedLanguageIds @@ -234,6 +246,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 organizationId = await getOrganizationIdFromEnvironmentId(environmentId); const organization = await getOrganizationAIKeys(organizationId); if (!organization) { 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); From 4642cc60c921b626c809eee1d1295ef8f529d50f Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Sun, 2 Nov 2025 17:59:16 +0530 Subject: [PATCH 2/9] fix: coderabbit feedback --- apps/web/lib/survey/service.ts | 4 +- .../modules/survey/editor/lib/blocks.test.ts | 56 +++++++++++++++++++ apps/web/modules/survey/editor/lib/blocks.ts | 14 ++++- 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/apps/web/lib/survey/service.ts b/apps/web/lib/survey/service.ts index 699704dcbc..409422c283 100644 --- a/apps/web/lib/survey/service.ts +++ b/apps/web/lib/survey/service.ts @@ -627,12 +627,14 @@ export const createSurvey = async ( checkForInvalidImagesInQuestions(data.questions); } - // Add blocks validation + // Add blocks validation and strip isDraft if (data.blocks && data.blocks.length > 0) { const blocksValidation = checkForInvalidImagesInBlocks(data.blocks); if (!blocksValidation.ok) { throw blocksValidation.error; } + // Strip isDraft from elements before persisting + data.blocks = stripIsDraftFromBlocks(data.blocks); } const survey = await prisma.survey.create({ diff --git a/apps/web/modules/survey/editor/lib/blocks.test.ts b/apps/web/modules/survey/editor/lib/blocks.test.ts index 1e5fa4274e..e00ce5c741 100644 --- a/apps/web/modules/survey/editor/lib/blocks.test.ts +++ b/apps/web/modules/survey/editor/lib/blocks.test.ts @@ -333,6 +333,26 @@ describe("Element Operations", () => { } }); + 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 = { @@ -365,6 +385,42 @@ describe("Element Operations", () => { } }); + 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", { diff --git a/apps/web/modules/survey/editor/lib/blocks.ts b/apps/web/modules/survey/editor/lib/blocks.ts index 18516620a1..d7c976a7ac 100644 --- a/apps/web/modules/survey/editor/lib/blocks.ts +++ b/apps/web/modules/survey/editor/lib/blocks.ts @@ -151,6 +151,7 @@ export const duplicateBlock = ( const elementsWithNewIds = blockToDuplicate.elements.map((element) => ({ ...element, id: createId(), + isDraft: true, })); const duplicatedBlock: TSurveyBlock = { @@ -239,9 +240,9 @@ export const addElementToBlock = ( return err(new Error(`Block with ID "${blockId}" not found`)); } - // Validate element ID is unique across all blocks - if (!isElementIdUnique(element.id, blocks, blockId)) { - return err(new Error(`Element ID "${element.id}" already exists in another block`)); + // 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] }; @@ -296,6 +297,13 @@ export const updateElementInBlock = ( 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, From 04f1e17e239d1ceed4090f164a22ec0b2c5565fe Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Mon, 3 Nov 2025 10:13:19 +0530 Subject: [PATCH 3/9] fix: tests --- apps/web/lib/survey/utils.test.ts | 373 +++++++++++++++++++++++++++++- apps/web/lib/survey/utils.ts | 4 +- 2 files changed, 374 insertions(+), 3 deletions(-) diff --git a/apps/web/lib/survey/utils.test.ts b/apps/web/lib/survey/utils.test.ts index fd2426cd98..3f7246dc47 100644 --- a/apps/web/lib/survey/utils.test.ts +++ b/apps/web/lib/survey/utils.test.ts @@ -2,9 +2,16 @@ 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 fileValidation from "@/modules/storage/utils"; -import { anySurveyHasFilters, checkForInvalidImagesInQuestions, transformPrismaSurvey } from "./utils"; +import { + anySurveyHasFilters, + checkForInvalidImagesInBlocks, + checkForInvalidImagesInQuestions, + transformPrismaSurvey, +} from "./utils"; describe("transformPrismaSurvey", () => { test("transforms prisma survey without segment", () => { @@ -252,3 +259,367 @@ describe("checkForInvalidImagesInQuestions", () => { expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(3, "image3.jpg"); }); }); + +describe("checkForInvalidImagesInBlocks", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + test("returns ok when blocks array is empty", () => { + const blocks: TSurveyBlock[] = []; + + const result = checkForInvalidImagesInBlocks(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 = checkForInvalidImagesInBlocks(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 = checkForInvalidImagesInBlocks(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 = checkForInvalidImagesInBlocks(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 element "welcome" in 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.MultipleChoiceSingle, + headline: { default: "Pick one" }, + required: true, + 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 = checkForInvalidImagesInBlocks(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 = checkForInvalidImagesInBlocks(blocks); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe( + 'Invalid image URL in choice 2 of element "pic-select" in block "Picture Selection"' + ); + } + }); + + 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 = checkForInvalidImagesInBlocks(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 = checkForInvalidImagesInBlocks(blocks); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe( + 'Invalid image URL in element "elem-2" (element 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.MultipleChoiceSingle, + headline: { default: "Pick one" }, + required: true, + choices: [ + { id: "c1", label: { default: "Option 1" } }, // No imageUrl + { id: "c2", label: { default: "Option 2" }, imageUrl: "image.jpg" }, + ], + } as unknown as TSurveyElement, + ], + }, + ]; + + const result = checkForInvalidImagesInBlocks(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 = checkForInvalidImagesInBlocks(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.MultipleChoiceSingle, + headline: { default: "Choose" }, + required: true, + imageUrl: "element-image.jpg", + choices: [ + { id: "c1", label: { default: "A" }, imageUrl: "choice1.jpg" }, + { id: "c2", label: { default: "B" }, imageUrl: "choice2.jpg" }, + ], + } as unknown as TSurveyElement, + ], + }, + ]; + + const result = checkForInvalidImagesInBlocks(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 49bce7a930..31c8ae754a 100644 --- a/apps/web/lib/survey/utils.ts +++ b/apps/web/lib/survey/utils.ts @@ -76,7 +76,7 @@ export const checkForInvalidImagesInBlocks = (blocks: TSurveyBlock[]): Result Date: Mon, 3 Nov 2025 10:28:02 +0530 Subject: [PATCH 4/9] fix: code duplication --- apps/web/lib/survey/service.ts | 10 +++------ apps/web/lib/survey/utils.ts | 23 ++++++++++++++++++++ apps/web/modules/survey/editor/lib/survey.ts | 18 +++------------ 3 files changed, 29 insertions(+), 22 deletions(-) diff --git a/apps/web/lib/survey/service.ts b/apps/web/lib/survey/service.ts index 409422c283..56d6e86539 100644 --- a/apps/web/lib/survey/service.ts +++ b/apps/web/lib/survey/service.ts @@ -20,6 +20,7 @@ import { checkForInvalidImagesInQuestions, stripIsDraftFromBlocks, transformPrismaSurvey, + validateAndPrepareBlocks, } from "./utils"; interface TriggerUpdate { @@ -627,14 +628,9 @@ export const createSurvey = async ( checkForInvalidImagesInQuestions(data.questions); } - // Add blocks validation and strip isDraft + // Validate and prepare blocks for persistence if (data.blocks && data.blocks.length > 0) { - const blocksValidation = checkForInvalidImagesInBlocks(data.blocks); - if (!blocksValidation.ok) { - throw blocksValidation.error; - } - // Strip isDraft from elements before persisting - data.blocks = stripIsDraftFromBlocks(data.blocks); + data.blocks = validateAndPrepareBlocks(data.blocks); } const survey = await prisma.survey.create({ diff --git a/apps/web/lib/survey/utils.ts b/apps/web/lib/survey/utils.ts index 31c8ae754a..2bb7ba5926 100644 --- a/apps/web/lib/survey/utils.ts +++ b/apps/web/lib/survey/utils.ts @@ -119,3 +119,26 @@ export const stripIsDraftFromBlocks = (blocks: TSurveyBlock[]): TSurveyBlock[] = }), })); }; + +/** + * Validates and prepares blocks for persistence + * - Validates all image URLs 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 image validation fails + */ +export const validateAndPrepareBlocks = (blocks: TSurveyBlock[]): TSurveyBlock[] => { + if (!blocks || blocks.length === 0) { + return blocks; + } + + // Validate images + const validation = checkForInvalidImagesInBlocks(blocks); + if (!validation.ok) { + throw validation.error; + } + + // Strip isDraft + return stripIsDraftFromBlocks(blocks); +}; diff --git a/apps/web/modules/survey/editor/lib/survey.ts b/apps/web/modules/survey/editor/lib/survey.ts index 3bcac4e925..87a3d2cdfb 100644 --- a/apps/web/modules/survey/editor/lib/survey.ts +++ b/apps/web/modules/survey/editor/lib/survey.ts @@ -4,11 +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 { - checkForInvalidImagesInBlocks, - checkForInvalidImagesInQuestions, - stripIsDraftFromBlocks, -} from "@/lib/survey/utils"; +import { checkForInvalidImagesInQuestions, validateAndPrepareBlocks } 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"; @@ -31,12 +27,9 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise => checkForInvalidImagesInQuestions(questions); - // Add blocks validation + // Validate and prepare blocks for persistence if (updatedSurvey.blocks && updatedSurvey.blocks.length > 0) { - const blocksValidation = checkForInvalidImagesInBlocks(updatedSurvey.blocks); - if (!blocksValidation.ok) { - throw blocksValidation.error; - } + data.blocks = validateAndPrepareBlocks(updatedSurvey.blocks); } if (languages) { @@ -246,11 +239,6 @@ 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 organizationId = await getOrganizationIdFromEnvironmentId(environmentId); const organization = await getOrganizationAIKeys(organizationId); if (!organization) { From 0910b0f1a7b33482a100cb85e9d5b7ec91da4b3e Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Mon, 3 Nov 2025 10:59:58 +0530 Subject: [PATCH 5/9] fix: sonar issues --- apps/web/lib/survey/utils.ts | 109 ++++++++++++++----- apps/web/modules/survey/editor/lib/blocks.ts | 8 +- 2 files changed, 86 insertions(+), 31 deletions(-) diff --git a/apps/web/lib/survey/utils.ts b/apps/web/lib/survey/utils.ts index 2bb7ba5926..5a754fb834 100644 --- a/apps/web/lib/survey/utils.ts +++ b/apps/web/lib/survey/utils.ts @@ -1,9 +1,11 @@ import "server-only"; import { Result, err, ok } from "@formbricks/types/error-handlers"; import { InvalidInputError } from "@formbricks/types/errors"; +import { TI18nString } from "@formbricks/types/i18n"; import { TJsEnvironmentStateSurvey } from "@formbricks/types/js"; import { TSegment } from "@formbricks/types/segment"; import { TSurveyBlock } from "@formbricks/types/surveys/blocks"; +import { TSurveyElement, TSurveyPictureChoice } from "@formbricks/types/surveys/elements"; import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; import { isValidImageFile } from "@/modules/storage/utils"; @@ -59,6 +61,83 @@ 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 elementId - Element ID 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 | { id: string; label: TI18nString; imageUrl?: string }, + choiceIdx: number, + elementId: string, + blockName: string +): Result => { + if ("imageUrl" in choice && choice.imageUrl && !isValidImageFile(choice.imageUrl)) { + return err( + new Error( + `Invalid image URL in choice ${choiceIdx + 1} of element "${elementId}" in block "${blockName}"` + ) + ); + } + return ok(undefined); +}; + +/** + * Validates all choices in an element + * @param element - Element with choices to validate + * @param elementId - Element ID for error reporting + * @param blockName - Block name for error reporting + * @returns Result with void data on success or Error on failure + */ +const validateElementChoices = ( + element: TSurveyElement, + elementId: string, + blockName: string +): Result => { + 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, elementId, blockName); + if (!result.ok) { + return result; + } + } + + return ok(undefined); +}; + +/** + * Validates a single element's image URL and choices + * @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 element "${element.id}" (element ${elementIdx + 1}) of block "${blockName}" (block ${blockIdx + 1})` + ) + ); + } + + // Check choices + return validateElementChoices(element, element.id, blockName); +}; + /** * Validates that all image URLs in blocks (elements and their choices) are valid * @param blocks - Array of survey blocks to validate @@ -69,33 +148,9 @@ export const checkForInvalidImagesInBlocks = (blocks: TSurveyBlock[]): Result blocks.length) { return err(new Error(`Invalid index ${index}. Must be between 0 and ${blocks.length}`)); } + blocks.splice(index, 0, newBlock); } else { blocks.push(newBlock); @@ -123,7 +124,6 @@ export const deleteBlock = (survey: TSurvey, blockId: string): Result elements.length) { return err(new Error(`Invalid index ${index}. Must be between 0 and ${elements.length}`)); } From e314feb4163fcf62d29cc78c171da10d51b82988 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Mon, 3 Nov 2025 11:18:11 +0530 Subject: [PATCH 6/9] fix --- .../modules/survey/editor/lib/blocks.test.ts | 10 ---- apps/web/modules/survey/editor/lib/blocks.ts | 47 ++++++++----------- 2 files changed, 19 insertions(+), 38 deletions(-) diff --git a/apps/web/modules/survey/editor/lib/blocks.test.ts b/apps/web/modules/survey/editor/lib/blocks.test.ts index e00ce5c741..8a7799f87e 100644 --- a/apps/web/modules/survey/editor/lib/blocks.test.ts +++ b/apps/web/modules/survey/editor/lib/blocks.test.ts @@ -219,16 +219,6 @@ describe("Block Operations", () => { } }); - test("should use custom name if provided", () => { - const survey = createMockSurvey(); - const result = duplicateBlock(survey, "block-1", { newName: "Custom Copy" }); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.data.blocks[1].name).toBe("Custom Copy"); - } - }); - test("should clear logic on duplicated block", () => { const survey = createMockSurvey(); survey.blocks[0].logic = [ diff --git a/apps/web/modules/survey/editor/lib/blocks.ts b/apps/web/modules/survey/editor/lib/blocks.ts index 5370b5311e..68cd74d666 100644 --- a/apps/web/modules/survey/editor/lib/blocks.ts +++ b/apps/web/modules/survey/editor/lib/blocks.ts @@ -127,16 +127,9 @@ export const deleteBlock = (survey: TSurvey, blockId: string): Result => { +export const duplicateBlock = (survey: TSurvey, blockId: string): Result => { const blocks = survey.blocks || []; const blockIndex = blocks.findIndex((b) => b.id === blockId); @@ -145,29 +138,28 @@ export const duplicateBlock = ( } const blockToDuplicate = blocks[blockIndex]; - const newBlockId = createId(); + + // 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 - const elementsWithNewIds = blockToDuplicate.elements.map((element) => ({ + duplicatedBlock.elements = duplicatedBlock.elements.map((element) => ({ ...element, id: createId(), isDraft: true, })); - const duplicatedBlock: TSurveyBlock = { - ...blockToDuplicate, - id: newBlockId, - name: options?.newName || `${blockToDuplicate.name} (copy)`, - elements: elementsWithNewIds, - // Clear logic since it references old block/element IDs - // In the future, we could map these references to the new IDs - logic: undefined, - logicFallback: undefined, - }; + // 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]; - const insertIndex = options?.insertAfter ? blockIndex + 1 : blockIndex; - updatedBlocks.splice(insertIndex, 0, duplicatedBlock); + updatedBlocks.splice(blockIndex + 1, 0, duplicatedBlock); return ok({ ...survey, @@ -355,7 +347,7 @@ export const deleteElementFromBlock = ( /** * Duplicates an element within a block - * Generates a new element ID with "_copy_" suffix + * 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 @@ -384,11 +376,10 @@ export const duplicateElementInBlock = ( const elementToDuplicate = elements[elementIndex]; - const duplicatedElement: TSurveyElement = { - ...elementToDuplicate, - id: createId(), - isDraft: true, - } as TSurveyElement; + // 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; From 5951eea6184fd50f39227daca71b55e0374cc023 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Mon, 3 Nov 2025 13:10:30 +0530 Subject: [PATCH 7/9] feedback --- apps/web/lib/survey/service.ts | 12 +-- apps/web/lib/survey/utils.test.ts | 100 ++++++++++++++---- apps/web/lib/survey/utils.ts | 53 +++++++--- apps/web/lib/utils/video-upload.ts | 9 ++ apps/web/locales/de-DE.json | 1 + apps/web/locales/en-US.json | 1 + apps/web/locales/fr-FR.json | 1 + apps/web/locales/ja-JP.json | 1 + apps/web/locales/pt-BR.json | 1 + apps/web/locales/pt-PT.json | 1 + apps/web/locales/ro-RO.json | 1 + apps/web/locales/zh-Hans-CN.json | 1 + apps/web/locales/zh-Hant-TW.json | 1 + .../modules/survey/editor/lib/blocks.test.ts | 23 ++-- apps/web/modules/survey/editor/lib/blocks.ts | 34 +++--- apps/web/modules/survey/editor/lib/survey.ts | 4 +- 16 files changed, 168 insertions(+), 76 deletions(-) diff --git a/apps/web/lib/survey/service.ts b/apps/web/lib/survey/service.ts index 56d6e86539..e76287750c 100644 --- a/apps/web/lib/survey/service.ts +++ b/apps/web/lib/survey/service.ts @@ -16,11 +16,11 @@ import { ITEMS_PER_PAGE } from "../constants"; import { capturePosthogEnvironmentEvent } from "../posthogServer"; import { validateInputs } from "../utils/validate"; import { - checkForInvalidImagesInBlocks, checkForInvalidImagesInQuestions, + checkForInvalidMediaInBlocks, stripIsDraftFromBlocks, transformPrismaSurvey, - validateAndPrepareBlocks, + validateMediaAndPrepareBlocks, } from "./utils"; interface TriggerUpdate { @@ -304,11 +304,11 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise => checkForInvalidImagesInQuestions(questions); - // Add blocks image validation + // Add blocks media validation if (updatedSurvey.blocks && updatedSurvey.blocks.length > 0) { - const blocksValidation = checkForInvalidImagesInBlocks(updatedSurvey.blocks); + const blocksValidation = checkForInvalidMediaInBlocks(updatedSurvey.blocks); if (!blocksValidation.ok) { - throw blocksValidation.error; + throw new InvalidInputError(blocksValidation.error.message); } } @@ -630,7 +630,7 @@ export const createSurvey = async ( // Validate and prepare blocks for persistence if (data.blocks && data.blocks.length > 0) { - data.blocks = validateAndPrepareBlocks(data.blocks); + data.blocks = validateMediaAndPrepareBlocks(data.blocks); } const survey = await prisma.survey.create({ diff --git a/apps/web/lib/survey/utils.test.ts b/apps/web/lib/survey/utils.test.ts index 3f7246dc47..d88bd7ac6f 100644 --- a/apps/web/lib/survey/utils.test.ts +++ b/apps/web/lib/survey/utils.test.ts @@ -5,11 +5,12 @@ 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, - checkForInvalidImagesInBlocks, checkForInvalidImagesInQuestions, + checkForInvalidMediaInBlocks, transformPrismaSurvey, } from "./utils"; @@ -268,7 +269,7 @@ describe("checkForInvalidImagesInBlocks", () => { test("returns ok when blocks array is empty", () => { const blocks: TSurveyBlock[] = []; - const result = checkForInvalidImagesInBlocks(blocks); + const result = checkForInvalidMediaInBlocks(blocks); expect(result.ok).toBe(true); }); @@ -290,7 +291,7 @@ describe("checkForInvalidImagesInBlocks", () => { }, ]; - const result = checkForInvalidImagesInBlocks(blocks); + const result = checkForInvalidMediaInBlocks(blocks); expect(result.ok).toBe(true); }); @@ -317,7 +318,7 @@ describe("checkForInvalidImagesInBlocks", () => { }, ]; - const result = checkForInvalidImagesInBlocks(blocks); + const result = checkForInvalidMediaInBlocks(blocks); expect(result.ok).toBe(true); expect(fileValidation.isValidImageFile).toHaveBeenCalledWith("image1.jpg"); @@ -346,7 +347,7 @@ describe("checkForInvalidImagesInBlocks", () => { }, ]; - const result = checkForInvalidImagesInBlocks(blocks); + const result = checkForInvalidMediaInBlocks(blocks); expect(result.ok).toBe(false); if (!result.ok) { @@ -368,19 +369,19 @@ describe("checkForInvalidImagesInBlocks", () => { elements: [ { id: "choice-q", - type: TSurveyElementTypeEnum.MultipleChoiceSingle, + type: TSurveyElementTypeEnum.PictureSelection, headline: { default: "Pick one" }, required: true, choices: [ - { id: "c1", label: { default: "Option 1" }, imageUrl: "image1.jpg" }, - { id: "c2", label: { default: "Option 2" }, imageUrl: "image2.jpg" }, + { id: "c1", imageUrl: "image1.jpg" }, + { id: "c2", imageUrl: "image2.jpg" }, ], } as unknown as TSurveyElement, ], }, ]; - const result = checkForInvalidImagesInBlocks(blocks); + const result = checkForInvalidMediaInBlocks(blocks); expect(result.ok).toBe(true); expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(2); @@ -410,7 +411,7 @@ describe("checkForInvalidImagesInBlocks", () => { }, ]; - const result = checkForInvalidImagesInBlocks(blocks); + const result = checkForInvalidMediaInBlocks(blocks); expect(result.ok).toBe(false); if (!result.ok) { @@ -420,6 +421,62 @@ describe("checkForInvalidImagesInBlocks", () => { } }); + 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("video-q"); + expect(result.error.message).toContain("YouTube, Vimeo, and Loom"); + } + }); + test("validates images across multiple blocks", () => { vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true); @@ -455,7 +512,7 @@ describe("checkForInvalidImagesInBlocks", () => { }, ]; - const result = checkForInvalidImagesInBlocks(blocks); + const result = checkForInvalidMediaInBlocks(blocks); expect(result.ok).toBe(true); expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(2); @@ -504,7 +561,7 @@ describe("checkForInvalidImagesInBlocks", () => { }, ]; - const result = checkForInvalidImagesInBlocks(blocks); + const result = checkForInvalidMediaInBlocks(blocks); expect(result.ok).toBe(false); if (!result.ok) { @@ -526,19 +583,16 @@ describe("checkForInvalidImagesInBlocks", () => { elements: [ { id: "mc-q", - type: TSurveyElementTypeEnum.MultipleChoiceSingle, + type: TSurveyElementTypeEnum.PictureSelection, headline: { default: "Pick one" }, required: true, - choices: [ - { id: "c1", label: { default: "Option 1" } }, // No imageUrl - { id: "c2", label: { default: "Option 2" }, imageUrl: "image.jpg" }, - ], + choices: [{ id: "c1", imageUrl: "image.jpg" }], } as unknown as TSurveyElement, ], }, ]; - const result = checkForInvalidImagesInBlocks(blocks); + const result = checkForInvalidMediaInBlocks(blocks); expect(result.ok).toBe(true); // Only validates the one with imageUrl @@ -582,7 +636,7 @@ describe("checkForInvalidImagesInBlocks", () => { }, ]; - const result = checkForInvalidImagesInBlocks(blocks); + const result = checkForInvalidMediaInBlocks(blocks); expect(result.ok).toBe(true); expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(3); @@ -601,20 +655,20 @@ describe("checkForInvalidImagesInBlocks", () => { elements: [ { id: "elem-1", - type: TSurveyElementTypeEnum.MultipleChoiceSingle, + type: TSurveyElementTypeEnum.PictureSelection, headline: { default: "Choose" }, required: true, imageUrl: "element-image.jpg", choices: [ - { id: "c1", label: { default: "A" }, imageUrl: "choice1.jpg" }, - { id: "c2", label: { default: "B" }, imageUrl: "choice2.jpg" }, + { id: "c1", imageUrl: "choice1.jpg" }, + { id: "c2", imageUrl: "choice2.jpg" }, ], } as unknown as TSurveyElement, ], }, ]; - const result = checkForInvalidImagesInBlocks(blocks); + const result = checkForInvalidMediaInBlocks(blocks); expect(result.ok).toBe(true); expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(3); diff --git a/apps/web/lib/survey/utils.ts b/apps/web/lib/survey/utils.ts index 5a754fb834..7261a3b1d4 100644 --- a/apps/web/lib/survey/utils.ts +++ b/apps/web/lib/survey/utils.ts @@ -5,8 +5,13 @@ import { TI18nString } from "@formbricks/types/i18n"; import { TJsEnvironmentStateSurvey } from "@formbricks/types/js"; import { TSegment } from "@formbricks/types/segment"; import { TSurveyBlock } from "@formbricks/types/surveys/blocks"; -import { TSurveyElement, TSurveyPictureChoice } from "@formbricks/types/surveys/elements"; +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 = ( @@ -86,17 +91,23 @@ const validateChoiceImage = ( }; /** - * Validates all choices in an element + * Validates choice images for picture selection elements + * Only picture selection questions have imageUrl in choices * @param element - Element with choices to validate * @param elementId - Element ID for error reporting * @param blockName - Block name for error reporting * @returns Result with void data on success or Error on failure */ -const validateElementChoices = ( +const validatePictureSelectionChoiceImages = ( element: TSurveyElement, elementId: string, 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); } @@ -112,7 +123,7 @@ const validateElementChoices = ( }; /** - * Validates a single element's image URL and choices + * 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 @@ -134,16 +145,28 @@ const validateElement = ( ); } - // Check choices - return validateElementChoices(element, element.id, blockName); + // Check element videoUrl + if (element.videoUrl && !isValidVideoUrl(element.videoUrl)) { + return err( + new Error( + `Invalid video URL in element "${element.id}" (element ${elementIdx + 1}) of block "${blockName}" (block ${blockIdx + 1}). Only YouTube, Vimeo, and Loom URLs are supported.` + ) + ); + } + + // Check choices for picture selection + return validatePictureSelectionChoiceImages(element, element.id, blockName); }; /** - * Validates that all image URLs in blocks (elements and their choices) are valid + * 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 checkForInvalidImagesInBlocks = (blocks: TSurveyBlock[]): Result => { +export const checkForInvalidMediaInBlocks = (blocks: TSurveyBlock[]): Result => { for (let blockIdx = 0; blockIdx < blocks.length; blockIdx++) { const block = blocks[blockIdx]; @@ -177,19 +200,15 @@ export const stripIsDraftFromBlocks = (blocks: TSurveyBlock[]): TSurveyBlock[] = /** * Validates and prepares blocks for persistence - * - Validates all image URLs in blocks + * - 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 image validation fails + * @throws Error if any media validation fails */ -export const validateAndPrepareBlocks = (blocks: TSurveyBlock[]): TSurveyBlock[] => { - if (!blocks || blocks.length === 0) { - return blocks; - } - - // Validate images - const validation = checkForInvalidImagesInBlocks(blocks); +export const validateMediaAndPrepareBlocks = (blocks: TSurveyBlock[]): TSurveyBlock[] => { + // Validate media (images and videos) + const validation = checkForInvalidMediaInBlocks(blocks); if (!validation.ok) { throw validation.error; } 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 index 8a7799f87e..42a06dea70 100644 --- a/apps/web/modules/survey/editor/lib/blocks.test.ts +++ b/apps/web/modules/survey/editor/lib/blocks.test.ts @@ -1,3 +1,4 @@ +import { TFunction } from "i18next"; import { describe, expect, test } from "vitest"; import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { TSurvey } from "@formbricks/types/surveys/types"; @@ -14,6 +15,14 @@ import { 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", @@ -102,12 +111,6 @@ describe("Block Utility Functions", () => { const isUnique = isElementIdUnique("elem-1", survey.blocks); expect(isUnique).toBe(false); }); - - test("should skip current block when provided", () => { - const survey = createMockSurvey(); - const isUnique = isElementIdUnique("elem-1", survey.blocks, "block-1"); - expect(isUnique).toBe(true); // Skips block-1 where elem-1 exists - }); }); }); @@ -115,7 +118,7 @@ describe("Block Operations", () => { describe("addBlock", () => { test("should add a block to the end by default", () => { const survey = createMockSurvey(); - const result = addBlock(survey, { name: "Block 3", elements: [] }); + const result = addBlock(mockT, survey, { name: "Block 3", elements: [] }); expect(result.ok).toBe(true); if (result.ok) { @@ -127,7 +130,7 @@ describe("Block Operations", () => { test("should add a block at specific index", () => { const survey = createMockSurvey(); - const result = addBlock(survey, { name: "Block 1.5", elements: [] }, 1); + const result = addBlock(mockT, survey, { name: "Block 1.5", elements: [] }, 1); expect(result.ok).toBe(true); if (result.ok) { @@ -138,7 +141,7 @@ describe("Block Operations", () => { test("should return error for invalid index", () => { const survey = createMockSurvey(); - const result = addBlock(survey, { name: "Block X", elements: [] }, 10); + const result = addBlock(mockT, survey, { name: "Block X", elements: [] }, 10); expect(result.ok).toBe(false); if (!result.ok) { @@ -148,7 +151,7 @@ describe("Block Operations", () => { test("should use default name if not provided", () => { const survey = createMockSurvey(); - const result = addBlock(survey, { elements: [] }); + const result = addBlock(mockT, survey, { elements: [] }); expect(result.ok).toBe(true); if (result.ok) { diff --git a/apps/web/modules/survey/editor/lib/blocks.ts b/apps/web/modules/survey/editor/lib/blocks.ts index 68cd74d666..1dae0eab18 100644 --- a/apps/web/modules/survey/editor/lib/blocks.ts +++ b/apps/web/modules/survey/editor/lib/blocks.ts @@ -1,4 +1,5 @@ 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"; @@ -12,18 +13,10 @@ import { TSurvey } from "@formbricks/types/surveys/types"; * 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 - * @param currentBlockId - Optional block ID to skip (for updates within same block) * @returns true if the element ID is unique, false otherwise */ -export const isElementIdUnique = ( - elementId: string, - blocks: TSurveyBlock[], - currentBlockId?: string -): boolean => { +export const isElementIdUnique = (elementId: string, blocks: TSurveyBlock[]): boolean => { for (const block of blocks) { - // Skip current block if provided (for updates within same block) - if (currentBlockId && block.id === currentBlockId) continue; - if (block.elements.some((e) => e.id === elementId)) { return false; } @@ -43,6 +36,7 @@ export const isElementIdUnique = ( * @returns Result with updated survey or Error */ export const addBlock = ( + t: TFunction, survey: TSurvey, block: Omit, "id">, index?: number @@ -51,13 +45,13 @@ export const addBlock = ( const blocks = [...(survey.blocks || [])]; const newBlock: TSurveyBlock = { - id: createId(), - name: block.name || "Untitled Block", - elements: block.elements || [], ...block, + id: createId(), + name: block.name || t("environments.surveys.edit.untitled_block"), + elements: block.elements || [], }; - if (index) { + if (index !== undefined) { if (index < 0 || index > blocks.length) { return err(new Error(`Invalid index ${index}. Must be between 0 and ${blocks.length}`)); } @@ -81,8 +75,13 @@ export const addBlock = ( export const updateBlock = ( survey: TSurvey, blockId: string, - updatedAttributes: Partial + 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); @@ -108,10 +107,9 @@ export const updateBlock = ( * @returns Result with updated survey or Error */ export const deleteBlock = (survey: TSurvey, blockId: string): Result => { - const blocks = [...(survey.blocks || [])]; - const filteredBlocks = blocks.filter((b) => b.id !== blockId); + const filteredBlocks = survey.blocks?.filter((b) => b.id !== blockId) || []; - if (filteredBlocks.length === blocks.length) { + if (filteredBlocks.length === survey.blocks?.length) { return err(new Error(`Block with ID "${blockId}" not found`)); } @@ -242,7 +240,7 @@ export const addElementToBlock = ( const elementWithDraft = { ...element, isDraft: true }; - if (index) { + if (index !== undefined) { if (index < 0 || index > elements.length) { return err(new Error(`Invalid index ${index}. Must be between 0 and ${elements.length}`)); } diff --git a/apps/web/modules/survey/editor/lib/survey.ts b/apps/web/modules/survey/editor/lib/survey.ts index 87a3d2cdfb..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, validateAndPrepareBlocks } 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"; @@ -29,7 +29,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise => // Validate and prepare blocks for persistence if (updatedSurvey.blocks && updatedSurvey.blocks.length > 0) { - data.blocks = validateAndPrepareBlocks(updatedSurvey.blocks); + data.blocks = validateMediaAndPrepareBlocks(updatedSurvey.blocks); } if (languages) { From 452617529cce9a1badddd39c7d74f0e3d9896ed5 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Mon, 3 Nov 2025 14:11:53 +0530 Subject: [PATCH 8/9] updates error message --- apps/web/lib/survey/utils.test.ts | 12 +++++------- apps/web/lib/survey/utils.ts | 18 +++++++++--------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/apps/web/lib/survey/utils.test.ts b/apps/web/lib/survey/utils.test.ts index d88bd7ac6f..1b0ab31a3c 100644 --- a/apps/web/lib/survey/utils.test.ts +++ b/apps/web/lib/survey/utils.test.ts @@ -261,7 +261,7 @@ describe("checkForInvalidImagesInQuestions", () => { }); }); -describe("checkForInvalidImagesInBlocks", () => { +describe("checkForInvalidMediaInBlocks", () => { beforeEach(() => { vi.resetAllMocks(); }); @@ -353,7 +353,7 @@ describe("checkForInvalidImagesInBlocks", () => { if (!result.ok) { console.log(result.error); expect(result.error.message).toBe( - 'Invalid image URL in choice 1 of element "welcome" in block "Welcome Block"' + 'Invalid image URL in choice 1 of question 1 of block "Welcome Block"' ); } expect(fileValidation.isValidImageFile).toHaveBeenCalledWith("image1.jpg"); @@ -416,7 +416,7 @@ describe("checkForInvalidImagesInBlocks", () => { expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.message).toBe( - 'Invalid image URL in choice 2 of element "pic-select" in block "Picture Selection"' + 'Invalid image URL in choice 2 of question 1 of block "Picture Selection"' ); } }); @@ -472,7 +472,7 @@ describe("checkForInvalidImagesInBlocks", () => { expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.message).toContain("Invalid video URL"); - expect(result.error.message).toContain("video-q"); + expect(result.error.message).toContain("question 1"); expect(result.error.message).toContain("YouTube, Vimeo, and Loom"); } }); @@ -565,9 +565,7 @@ describe("checkForInvalidImagesInBlocks", () => { expect(result.ok).toBe(false); if (!result.ok) { - expect(result.error.message).toBe( - 'Invalid image URL in element "elem-2" (element 1) of block "Block 2" (block 2)' - ); + 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); diff --git a/apps/web/lib/survey/utils.ts b/apps/web/lib/survey/utils.ts index 7261a3b1d4..61288383dc 100644 --- a/apps/web/lib/survey/utils.ts +++ b/apps/web/lib/survey/utils.ts @@ -70,20 +70,20 @@ 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 elementId - Element ID 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 | { id: string; label: TI18nString; imageUrl?: string }, choiceIdx: number, - elementId: string, + questionIdx: number, blockName: string ): Result => { if ("imageUrl" in choice && choice.imageUrl && !isValidImageFile(choice.imageUrl)) { return err( new Error( - `Invalid image URL in choice ${choiceIdx + 1} of element "${elementId}" in block "${blockName}"` + `Invalid image URL in choice ${choiceIdx + 1} of question ${questionIdx + 1} of block "${blockName}"` ) ); } @@ -94,13 +94,13 @@ const validateChoiceImage = ( * Validates choice images for picture selection elements * Only picture selection questions have imageUrl in choices * @param element - Element with choices to validate - * @param elementId - Element ID 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 validatePictureSelectionChoiceImages = ( element: TSurveyElement, - elementId: string, + questionIdx: number, blockName: string ): Result => { // Only validate choices for picture selection questions @@ -113,7 +113,7 @@ const validatePictureSelectionChoiceImages = ( } for (let choiceIdx = 0; choiceIdx < element.choices.length; choiceIdx++) { - const result = validateChoiceImage(element.choices[choiceIdx], choiceIdx, elementId, blockName); + const result = validateChoiceImage(element.choices[choiceIdx], choiceIdx, questionIdx, blockName); if (!result.ok) { return result; } @@ -140,7 +140,7 @@ const validateElement = ( if (element.imageUrl && !isValidImageFile(element.imageUrl)) { return err( new Error( - `Invalid image URL in element "${element.id}" (element ${elementIdx + 1}) of block "${blockName}" (block ${blockIdx + 1})` + `Invalid image URL in question ${elementIdx + 1} of block "${blockName}" (block ${blockIdx + 1})` ) ); } @@ -149,13 +149,13 @@ const validateElement = ( if (element.videoUrl && !isValidVideoUrl(element.videoUrl)) { return err( new Error( - `Invalid video URL in element "${element.id}" (element ${elementIdx + 1}) of block "${blockName}" (block ${blockIdx + 1}). Only YouTube, Vimeo, and Loom URLs are supported.` + `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, element.id, blockName); + return validatePictureSelectionChoiceImages(element, elementIdx, blockName); }; /** From 33eadaaa7b096d42b06e6c0e591338b138ecac63 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Mon, 3 Nov 2025 16:37:24 +0530 Subject: [PATCH 9/9] feedback --- apps/web/lib/survey/utils.ts | 5 ++--- apps/web/modules/survey/editor/lib/blocks.ts | 12 ++++++------ 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/apps/web/lib/survey/utils.ts b/apps/web/lib/survey/utils.ts index 61288383dc..8f9eed47d0 100644 --- a/apps/web/lib/survey/utils.ts +++ b/apps/web/lib/survey/utils.ts @@ -1,7 +1,6 @@ import "server-only"; import { Result, err, ok } from "@formbricks/types/error-handlers"; import { InvalidInputError } from "@formbricks/types/errors"; -import { TI18nString } from "@formbricks/types/i18n"; import { TJsEnvironmentStateSurvey } from "@formbricks/types/js"; import { TSegment } from "@formbricks/types/segment"; import { TSurveyBlock } from "@formbricks/types/surveys/blocks"; @@ -75,12 +74,12 @@ export const checkForInvalidImagesInQuestions = (questions: TSurveyQuestion[]) = * @returns Result with void data on success or Error on failure */ const validateChoiceImage = ( - choice: TSurveyPictureChoice | { id: string; label: TI18nString; imageUrl?: string }, + choice: TSurveyPictureChoice, choiceIdx: number, questionIdx: number, blockName: string ): Result => { - if ("imageUrl" in choice && choice.imageUrl && !isValidImageFile(choice.imageUrl)) { + if (choice.imageUrl && !isValidImageFile(choice.imageUrl)) { return err( new Error( `Invalid image URL in choice ${choiceIdx + 1} of question ${questionIdx + 1} of block "${blockName}"` diff --git a/apps/web/modules/survey/editor/lib/blocks.ts b/apps/web/modules/survey/editor/lib/blocks.ts index 1dae0eab18..8470366790 100644 --- a/apps/web/modules/survey/editor/lib/blocks.ts +++ b/apps/web/modules/survey/editor/lib/blocks.ts @@ -51,14 +51,14 @@ export const addBlock = ( elements: block.elements || [], }; - if (index !== undefined) { + 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); - } else { - blocks.push(newBlock); } updatedSurvey.blocks = blocks; @@ -240,13 +240,13 @@ export const addElementToBlock = ( const elementWithDraft = { ...element, isDraft: true }; - if (index !== undefined) { + 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); - } else { - elements.push(elementWithDraft); } block.elements = elements;