feat(blocks): add editor utilities, validation, and unit tests for bl… (#6768)

This commit is contained in:
Anshuman Pandey
2025-11-03 20:40:52 +05:30
committed by GitHub
18 changed files with 1511 additions and 58 deletions

View File

@@ -15,7 +15,13 @@ import { getActionClasses } from "../actionClass/service";
import { ITEMS_PER_PAGE } from "../constants";
import { capturePosthogEnvironmentEvent } from "../posthogServer";
import { validateInputs } from "../utils/validate";
import { checkForInvalidImagesInQuestions, transformPrismaSurvey } from "./utils";
import {
checkForInvalidImagesInQuestions,
checkForInvalidMediaInBlocks,
stripIsDraftFromBlocks,
transformPrismaSurvey,
validateMediaAndPrepareBlocks,
} from "./utils";
interface TriggerUpdate {
create?: Array<{ actionClassId: string }>;
@@ -298,6 +304,14 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
checkForInvalidImagesInQuestions(questions);
// Add blocks media validation
if (updatedSurvey.blocks && updatedSurvey.blocks.length > 0) {
const blocksValidation = checkForInvalidMediaInBlocks(updatedSurvey.blocks);
if (!blocksValidation.ok) {
throw new InvalidInputError(blocksValidation.error.message);
}
}
if (languages) {
// Process languages update logic here
// Extract currentLanguageIds and updatedLanguageIds
@@ -505,6 +519,11 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
return rest;
});
// Strip isDraft from elements before saving
if (updatedSurvey.blocks && updatedSurvey.blocks.length > 0) {
data.blocks = stripIsDraftFromBlocks(updatedSurvey.blocks);
}
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new ResourceNotFoundError("Organization", null);
@@ -609,6 +628,11 @@ export const createSurvey = async (
checkForInvalidImagesInQuestions(data.questions);
}
// Validate and prepare blocks for persistence
if (data.blocks && data.blocks.length > 0) {
data.blocks = validateMediaAndPrepareBlocks(data.blocks);
}
const survey = await prisma.survey.create({
data: {
...data,
@@ -623,14 +647,6 @@ export const createSurvey = async (
// if the survey created is an "app" survey, we also create a private segment for it.
if (survey.type === "app") {
// const newSegment = await createSegment({
// environmentId: parsedEnvironmentId,
// surveyId: survey.id,
// filters: [],
// title: survey.id,
// isPrivate: true,
// });
const newSegment = await prisma.segment.create({
data: {
title: survey.id,

View File

@@ -2,9 +2,17 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { InvalidInputError } from "@formbricks/types/errors";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TSegment } from "@formbricks/types/segment";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import * as videoValidation from "@/lib/utils/video-upload";
import * as fileValidation from "@/modules/storage/utils";
import { anySurveyHasFilters, checkForInvalidImagesInQuestions, transformPrismaSurvey } from "./utils";
import {
anySurveyHasFilters,
checkForInvalidImagesInQuestions,
checkForInvalidMediaInBlocks,
transformPrismaSurvey,
} from "./utils";
describe("transformPrismaSurvey", () => {
test("transforms prisma survey without segment", () => {
@@ -252,3 +260,418 @@ describe("checkForInvalidImagesInQuestions", () => {
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(3, "image3.jpg");
});
});
describe("checkForInvalidMediaInBlocks", () => {
beforeEach(() => {
vi.resetAllMocks();
});
test("returns ok when blocks array is empty", () => {
const blocks: TSurveyBlock[] = [];
const result = checkForInvalidMediaInBlocks(blocks);
expect(result.ok).toBe(true);
});
test("returns ok when blocks have no images", () => {
const blocks: TSurveyBlock[] = [
{
id: "block-1",
name: "Block 1",
elements: [
{
id: "elem-1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Question" },
required: false,
inputType: "text",
} as unknown as TSurveyElement,
],
},
];
const result = checkForInvalidMediaInBlocks(blocks);
expect(result.ok).toBe(true);
});
test("returns ok when all element images are valid", () => {
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true);
const blocks: TSurveyBlock[] = [
{
id: "block-1",
name: "Block 1",
elements: [
{
id: "elem-1",
type: TSurveyElementTypeEnum.PictureSelection,
headline: { default: "Question" },
required: false,
choices: [
{ id: "c1", label: { default: "Option 1" }, imageUrl: "image1.jpg" },
{ id: "c2", label: { default: "Option 2" }, imageUrl: "image2.jpg" },
],
} as unknown as TSurveyElement,
],
},
];
const result = checkForInvalidMediaInBlocks(blocks);
expect(result.ok).toBe(true);
expect(fileValidation.isValidImageFile).toHaveBeenCalledWith("image1.jpg");
expect(fileValidation.isValidImageFile).toHaveBeenCalledWith("image2.jpg");
});
test("returns error when element image is invalid", () => {
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(false);
const blocks: TSurveyBlock[] = [
{
id: "block-1",
name: "Welcome Block",
elements: [
{
id: "welcome",
type: TSurveyElementTypeEnum.PictureSelection,
headline: { default: "Welcome" },
required: false,
choices: [
{ id: "c1", label: { default: "Option 1" }, imageUrl: "image1.jpg" },
{ id: "c2", label: { default: "Option 2" }, imageUrl: "image2.jpg" },
],
} as unknown as TSurveyElement,
],
},
];
const result = checkForInvalidMediaInBlocks(blocks);
expect(result.ok).toBe(false);
if (!result.ok) {
console.log(result.error);
expect(result.error.message).toBe(
'Invalid image URL in choice 1 of question 1 of block "Welcome Block"'
);
}
expect(fileValidation.isValidImageFile).toHaveBeenCalledWith("image1.jpg");
});
test("returns ok when all choice images are valid", () => {
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true);
const blocks: TSurveyBlock[] = [
{
id: "block-1",
name: "Choice Block",
elements: [
{
id: "choice-q",
type: TSurveyElementTypeEnum.PictureSelection,
headline: { default: "Pick one" },
required: true,
choices: [
{ id: "c1", imageUrl: "image1.jpg" },
{ id: "c2", imageUrl: "image2.jpg" },
],
} as unknown as TSurveyElement,
],
},
];
const result = checkForInvalidMediaInBlocks(blocks);
expect(result.ok).toBe(true);
expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(2);
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(1, "image1.jpg");
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(2, "image2.jpg");
});
test("returns error when choice image is invalid", () => {
vi.spyOn(fileValidation, "isValidImageFile").mockImplementation((url) => url === "valid.jpg");
const blocks: TSurveyBlock[] = [
{
id: "block-1",
name: "Picture Selection",
elements: [
{
id: "pic-select",
type: TSurveyElementTypeEnum.PictureSelection,
headline: { default: "Select a picture" },
required: true,
choices: [
{ id: "c1", imageUrl: "valid.jpg" },
{ id: "c2", imageUrl: "invalid.txt" },
],
} as unknown as TSurveyElement,
],
},
];
const result = checkForInvalidMediaInBlocks(blocks);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toBe(
'Invalid image URL in choice 2 of question 1 of block "Picture Selection"'
);
}
});
test("returns ok when video URL is valid (YouTube)", () => {
vi.spyOn(videoValidation, "isValidVideoUrl").mockReturnValue(true);
const blocks: TSurveyBlock[] = [
{
id: "block-1",
name: "Video Block",
elements: [
{
id: "video-q",
type: TSurveyElementTypeEnum.CTA,
headline: { default: "Watch this" },
required: false,
videoUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
} as unknown as TSurveyElement,
],
},
];
const result = checkForInvalidMediaInBlocks(blocks);
expect(result.ok).toBe(true);
expect(videoValidation.isValidVideoUrl).toHaveBeenCalledWith(
"https://www.youtube.com/watch?v=dQw4w9WgXcQ"
);
});
test("returns error when video URL is invalid (not YouTube/Vimeo/Loom)", () => {
vi.spyOn(videoValidation, "isValidVideoUrl").mockReturnValue(false);
const blocks: TSurveyBlock[] = [
{
id: "block-1",
name: "Video Block",
elements: [
{
id: "video-q",
type: TSurveyElementTypeEnum.CTA,
headline: { default: "Watch this" },
required: false,
videoUrl: "https://example.com/video.mp4",
} as unknown as TSurveyElement,
],
},
];
const result = checkForInvalidMediaInBlocks(blocks);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toContain("Invalid video URL");
expect(result.error.message).toContain("question 1");
expect(result.error.message).toContain("YouTube, Vimeo, and Loom");
}
});
test("validates images across multiple blocks", () => {
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true);
const blocks: TSurveyBlock[] = [
{
id: "block-1",
name: "Block 1",
elements: [
{
id: "elem-1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q1" },
required: false,
inputType: "text",
imageUrl: "image1.jpg",
} as unknown as TSurveyElement,
],
},
{
id: "block-2",
name: "Block 2",
elements: [
{
id: "elem-2",
type: TSurveyElementTypeEnum.Rating,
headline: { default: "Q2" },
required: true,
range: 5,
scale: "star",
imageUrl: "image2.jpg",
} as unknown as TSurveyElement,
],
},
];
const result = checkForInvalidMediaInBlocks(blocks);
expect(result.ok).toBe(true);
expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(2);
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(1, "image1.jpg");
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(2, "image2.jpg");
});
test("stops at first invalid image and returns specific error", () => {
vi.spyOn(fileValidation, "isValidImageFile").mockImplementation((url) => url !== "bad-image.gif");
const blocks: TSurveyBlock[] = [
{
id: "block-1",
name: "Block 1",
elements: [
{
id: "elem-1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q1" },
required: false,
inputType: "text",
imageUrl: "good.jpg",
} as unknown as TSurveyElement,
],
},
{
id: "block-2",
name: "Block 2",
elements: [
{
id: "elem-2",
type: TSurveyElementTypeEnum.CTA,
headline: { default: "Q2" },
required: false,
imageUrl: "bad-image.gif",
} as unknown as TSurveyElement,
{
id: "elem-3",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q3" },
required: false,
inputType: "text",
imageUrl: "another.jpg",
} as unknown as TSurveyElement,
],
},
];
const result = checkForInvalidMediaInBlocks(blocks);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toBe('Invalid image URL in question 1 of block "Block 2" (block 2)');
}
// Should stop after finding first invalid image
expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(2);
});
test("validates choices without imageUrl (skips gracefully)", () => {
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true);
const blocks: TSurveyBlock[] = [
{
id: "block-1",
name: "Choice Block",
elements: [
{
id: "mc-q",
type: TSurveyElementTypeEnum.PictureSelection,
headline: { default: "Pick one" },
required: true,
choices: [{ id: "c1", imageUrl: "image.jpg" }],
} as unknown as TSurveyElement,
],
},
];
const result = checkForInvalidMediaInBlocks(blocks);
expect(result.ok).toBe(true);
// Only validates the one with imageUrl
expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(1);
expect(fileValidation.isValidImageFile).toHaveBeenCalledWith("image.jpg");
});
test("handles multiple elements in single block", () => {
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true);
const blocks: TSurveyBlock[] = [
{
id: "block-1",
name: "Multi-Element Block",
elements: [
{
id: "elem-1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q1" },
required: false,
inputType: "text",
imageUrl: "img1.jpg",
} as unknown as TSurveyElement,
{
id: "elem-2",
type: TSurveyElementTypeEnum.Rating,
headline: { default: "Q2" },
required: true,
range: 5,
scale: "number",
imageUrl: "img2.jpg",
} as unknown as TSurveyElement,
{
id: "elem-3",
type: TSurveyElementTypeEnum.CTA,
headline: { default: "Q3" },
required: false,
imageUrl: "img3.jpg",
} as unknown as TSurveyElement,
],
},
];
const result = checkForInvalidMediaInBlocks(blocks);
expect(result.ok).toBe(true);
expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(3);
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(1, "img1.jpg");
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(2, "img2.jpg");
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(3, "img3.jpg");
});
test("validates both element imageUrl and choice imageUrls", () => {
vi.spyOn(fileValidation, "isValidImageFile").mockReturnValue(true);
const blocks: TSurveyBlock[] = [
{
id: "block-1",
name: "Complex Block",
elements: [
{
id: "elem-1",
type: TSurveyElementTypeEnum.PictureSelection,
headline: { default: "Choose" },
required: true,
imageUrl: "element-image.jpg",
choices: [
{ id: "c1", imageUrl: "choice1.jpg" },
{ id: "c2", imageUrl: "choice2.jpg" },
],
} as unknown as TSurveyElement,
],
},
];
const result = checkForInvalidMediaInBlocks(blocks);
expect(result.ok).toBe(true);
expect(fileValidation.isValidImageFile).toHaveBeenCalledTimes(3);
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(1, "element-image.jpg");
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(2, "choice1.jpg");
expect(fileValidation.isValidImageFile).toHaveBeenNthCalledWith(3, "choice2.jpg");
});
});

View File

@@ -1,8 +1,16 @@
import "server-only";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { InvalidInputError } from "@formbricks/types/errors";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { TSegment } from "@formbricks/types/segment";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import {
TSurveyElement,
TSurveyElementTypeEnum,
TSurveyPictureChoice,
} from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { isValidVideoUrl } from "@/lib/utils/video-upload";
import { isValidImageFile } from "@/modules/storage/utils";
export const transformPrismaSurvey = <T extends TSurvey | TJsEnvironmentStateSurvey>(
@@ -56,3 +64,154 @@ export const checkForInvalidImagesInQuestions = (questions: TSurveyQuestion[]) =
}
});
};
/**
* Validates a single choice's image URL
* @param choice - Choice to validate
* @param choiceIdx - Index of the choice for error reporting
* @param questionIdx - Index of the question for error reporting
* @param blockName - Block name for error reporting
* @returns Result with void data on success or Error on failure
*/
const validateChoiceImage = (
choice: TSurveyPictureChoice,
choiceIdx: number,
questionIdx: number,
blockName: string
): Result<void, Error> => {
if (choice.imageUrl && !isValidImageFile(choice.imageUrl)) {
return err(
new Error(
`Invalid image URL in choice ${choiceIdx + 1} of question ${questionIdx + 1} of block "${blockName}"`
)
);
}
return ok(undefined);
};
/**
* Validates choice images for picture selection elements
* Only picture selection questions have imageUrl in choices
* @param element - Element with choices to validate
* @param questionIdx - Index of the question for error reporting
* @param blockName - Block name for error reporting
* @returns Result with void data on success or Error on failure
*/
const validatePictureSelectionChoiceImages = (
element: TSurveyElement,
questionIdx: number,
blockName: string
): Result<void, Error> => {
// Only validate choices for picture selection questions
if (element.type !== TSurveyElementTypeEnum.PictureSelection) {
return ok(undefined);
}
if (!("choices" in element) || !Array.isArray(element.choices)) {
return ok(undefined);
}
for (let choiceIdx = 0; choiceIdx < element.choices.length; choiceIdx++) {
const result = validateChoiceImage(element.choices[choiceIdx], choiceIdx, questionIdx, blockName);
if (!result.ok) {
return result;
}
}
return ok(undefined);
};
/**
* Validates a single element's image URL, video URL, and picture selection choice images
* @param element - Element to validate
* @param elementIdx - Index of the element for error reporting
* @param blockIdx - Index of the block for error reporting
* @param blockName - Block name for error reporting
* @returns Result with void data on success or Error on failure
*/
const validateElement = (
element: TSurveyElement,
elementIdx: number,
blockIdx: number,
blockName: string
): Result<void, Error> => {
// Check element imageUrl
if (element.imageUrl && !isValidImageFile(element.imageUrl)) {
return err(
new Error(
`Invalid image URL in question ${elementIdx + 1} of block "${blockName}" (block ${blockIdx + 1})`
)
);
}
// Check element videoUrl
if (element.videoUrl && !isValidVideoUrl(element.videoUrl)) {
return err(
new Error(
`Invalid video URL in question ${elementIdx + 1} of block "${blockName}" (block ${blockIdx + 1}). Only YouTube, Vimeo, and Loom URLs are supported.`
)
);
}
// Check choices for picture selection
return validatePictureSelectionChoiceImages(element, elementIdx, blockName);
};
/**
* Validates that all media URLs (images and videos) in blocks are valid
* - Validates element imageUrl
* - Validates element videoUrl
* - Validates choice imageUrl for picture selection elements
* @param blocks - Array of survey blocks to validate
* @returns Result with void data on success or Error on failure
*/
export const checkForInvalidMediaInBlocks = (blocks: TSurveyBlock[]): Result<void, Error> => {
for (let blockIdx = 0; blockIdx < blocks.length; blockIdx++) {
const block = blocks[blockIdx];
for (let elementIdx = 0; elementIdx < block.elements.length; elementIdx++) {
const result = validateElement(block.elements[elementIdx], elementIdx, blockIdx, block.name);
if (!result.ok) {
return result;
}
}
}
return ok(undefined);
};
/**
* Strips isDraft field from elements before saving to database
* Note: Blocks don't have isDraft since block IDs are CUIDs (not user-editable)
* Only element IDs need protection as they're user-editable and used in responses
* @param blocks - Array of survey blocks
* @returns New array with isDraft stripped from all elements
*/
export const stripIsDraftFromBlocks = (blocks: TSurveyBlock[]): TSurveyBlock[] => {
return blocks.map((block) => ({
...block,
elements: block.elements.map((element) => {
const { isDraft, ...elementRest } = element;
return elementRest;
}),
}));
};
/**
* Validates and prepares blocks for persistence
* - Validates all media URLs (images and videos) in blocks
* - Strips isDraft flags from elements
* @param blocks - Array of survey blocks to validate and prepare
* @returns Prepared blocks ready for database persistence
* @throws Error if any media validation fails
*/
export const validateMediaAndPrepareBlocks = (blocks: TSurveyBlock[]): TSurveyBlock[] => {
// Validate media (images and videos)
const validation = checkForInvalidMediaInBlocks(blocks);
if (!validation.ok) {
throw validation.error;
}
// Strip isDraft
return stripIsDraftFromBlocks(blocks);
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -1619,6 +1619,7 @@
"unlock_targeting_title": "上位プランでターゲティングをアンロック",
"unsaved_changes_warning": "フォームに未保存の変更があります。離れる前に保存しますか?",
"until_they_submit_a_response": "回答を送信するまで",
"untitled_block": "無題のブロック",
"upgrade_notice_description": "多言語フォームを作成し、さらに多くの機能をアンロック",
"upgrade_notice_title": "上位プランで多言語フォームをアンロック",
"upload": "アップロード",

View File

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

View File

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

View File

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

View File

@@ -1619,6 +1619,7 @@
"unlock_targeting_title": "通过 更 高级 划解锁 定位",
"unsaved_changes_warning": "您在调查中有未保存的更改。离开前是否要保存?",
"until_they_submit_a_response": "直到 他们 提交 回复",
"untitled_block": "未命名区块",
"upgrade_notice_description": "创建 多语言 调查 并 解锁 更多 功能",
"upgrade_notice_title": "解锁 更高 计划 中 的 多语言 调查",
"upload": "上传",

View File

@@ -1619,6 +1619,7 @@
"unlock_targeting_title": "使用更高等級的方案解鎖目標設定",
"unsaved_changes_warning": "您的問卷中有未儲存的變更。您要先儲存它們再離開嗎?",
"until_they_submit_a_response": "直到他們提交回應",
"untitled_block": "未命名區塊",
"upgrade_notice_description": "建立多語言問卷並解鎖更多功能",
"upgrade_notice_title": "使用更高等級的方案解鎖多語言問卷",
"upload": "上傳",

View File

@@ -0,0 +1,478 @@
import { TFunction } from "i18next";
import { describe, expect, test } from "vitest";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import {
addBlock,
addElementToBlock,
deleteBlock,
deleteElementFromBlock,
duplicateBlock,
duplicateElementInBlock,
isElementIdUnique,
moveBlock,
updateBlock,
updateElementInBlock,
} from "./blocks";
// Mock translation function
const mockT: TFunction = ((key: string) => {
const translations: Record<string, string> = {
"environments.surveys.edit.untitled_block": "Untitled Block",
};
return translations[key] || key;
}) as TFunction;
// Helper to create a mock survey
const createMockSurvey = (): TSurvey => ({
id: "test-survey-id",
name: "Test Survey",
type: "link",
environmentId: "test-env-id",
createdBy: null,
status: "draft",
welcomeCard: { enabled: false, timeToFinish: false, showResponseCount: false },
questions: [],
endings: [],
hiddenFields: { enabled: false },
variables: [],
displayOption: "displayOnce",
recontactDays: null,
displayLimit: null,
autoClose: null,
delay: 0,
displayPercentage: null,
autoComplete: null,
isVerifyEmailEnabled: false,
isSingleResponsePerEmailEnabled: false,
projectOverwrites: null,
styling: null,
surveyClosedMessage: null,
singleUse: null,
pin: null,
languages: [],
showLanguageSwitch: null,
segment: null,
triggers: [],
createdAt: new Date(),
updatedAt: new Date(),
blocks: [
{
id: "block-1",
name: "Block 1",
elements: [
{
id: "elem-1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Question 1" },
required: true,
inputType: "text",
} as any,
{
id: "elem-2",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Question 2" },
required: false,
inputType: "email",
} as any,
],
},
{
id: "block-2",
name: "Block 2",
elements: [
{
id: "elem-3",
type: TSurveyElementTypeEnum.Rating,
headline: { default: "Rate us" },
required: true,
scale: "star",
range: 5,
} as any,
],
},
],
followUps: [],
recaptcha: null,
isBackButtonHidden: false,
metadata: {},
});
describe("Block Utility Functions", () => {
describe("isElementIdUnique", () => {
test("should return true for unique element ID", () => {
const survey = createMockSurvey();
const isUnique = isElementIdUnique("new-elem", survey.blocks);
expect(isUnique).toBe(true);
});
test("should return false for duplicate element ID", () => {
const survey = createMockSurvey();
const isUnique = isElementIdUnique("elem-1", survey.blocks);
expect(isUnique).toBe(false);
});
});
});
describe("Block Operations", () => {
describe("addBlock", () => {
test("should add a block to the end by default", () => {
const survey = createMockSurvey();
const result = addBlock(mockT, survey, { name: "Block 3", elements: [] });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.blocks.length).toBe(3);
expect(result.data.blocks[2].name).toBe("Block 3");
expect(result.data.blocks[2].id).toBeTruthy();
}
});
test("should add a block at specific index", () => {
const survey = createMockSurvey();
const result = addBlock(mockT, survey, { name: "Block 1.5", elements: [] }, 1);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.blocks.length).toBe(3);
expect(result.data.blocks[1].name).toBe("Block 1.5");
}
});
test("should return error for invalid index", () => {
const survey = createMockSurvey();
const result = addBlock(mockT, survey, { name: "Block X", elements: [] }, 10);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toContain("Invalid index");
}
});
test("should use default name if not provided", () => {
const survey = createMockSurvey();
const result = addBlock(mockT, survey, { elements: [] });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.blocks[2].name).toBe("Untitled Block");
}
});
});
describe("updateBlock", () => {
test("should update block attributes", () => {
const survey = createMockSurvey();
const result = updateBlock(survey, "block-1", { name: "Updated Block 1" });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.blocks[0].name).toBe("Updated Block 1");
}
});
test("should return error for non-existent block", () => {
const survey = createMockSurvey();
const result = updateBlock(survey, "non-existent", { name: "Updated" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toContain("not found");
}
});
});
describe("deleteBlock", () => {
test("should delete a block", () => {
const survey = createMockSurvey();
const result = deleteBlock(survey, "block-1");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.blocks.length).toBe(1);
expect(result.data.blocks[0].id).toBe("block-2");
}
});
test("should return error for non-existent block", () => {
const survey = createMockSurvey();
const result = deleteBlock(survey, "non-existent");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toContain("not found");
}
});
});
describe("duplicateBlock", () => {
test("should duplicate a block with new IDs", () => {
const survey = createMockSurvey();
const result = duplicateBlock(survey, "block-1");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.blocks.length).toBe(3);
const duplicated = result.data.blocks[1];
expect(duplicated.name).toBe("Block 1 (copy)");
expect(duplicated.id).not.toBe("block-1");
expect(duplicated.elements.length).toBe(2);
// Element IDs should be different
expect(duplicated.elements[0].id).not.toBe("elem-1");
}
});
test("should clear logic on duplicated block", () => {
const survey = createMockSurvey();
survey.blocks[0].logic = [
{
id: "logic-1",
conditions: { connector: "and", conditions: [] },
actions: [],
},
] as any;
const result = duplicateBlock(survey, "block-1");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.blocks[1].logic).toBeUndefined();
}
});
});
describe("moveBlock", () => {
test("should move block down", () => {
const survey = createMockSurvey();
const result = moveBlock(survey, "block-1", "down");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.blocks[0].id).toBe("block-2");
expect(result.data.blocks[1].id).toBe("block-1");
}
});
test("should move block up", () => {
const survey = createMockSurvey();
const result = moveBlock(survey, "block-2", "up");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.blocks[0].id).toBe("block-2");
expect(result.data.blocks[1].id).toBe("block-1");
}
});
test("should return unchanged survey when moving first block up", () => {
const survey = createMockSurvey();
const result = moveBlock(survey, "block-1", "up");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.blocks[0].id).toBe("block-1");
}
});
test("should return unchanged survey when moving last block down", () => {
const survey = createMockSurvey();
const result = moveBlock(survey, "block-2", "down");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.blocks[1].id).toBe("block-2");
}
});
});
});
describe("Element Operations", () => {
describe("addElementToBlock", () => {
test("should add element to block", () => {
const survey = createMockSurvey();
const newElement = {
id: "elem-new",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "New Question" },
required: false,
inputType: "text",
} as any;
const result = addElementToBlock(survey, "block-1", newElement);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.blocks[0].elements.length).toBe(3);
expect(result.data.blocks[0].elements[2].id).toBe("elem-new");
expect(result.data.blocks[0].elements[2].isDraft).toBe(true);
}
});
test("should return error for duplicate element ID", () => {
const survey = createMockSurvey();
const duplicateElement = {
id: "elem-1", // Already exists
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Duplicate" },
required: false,
inputType: "text",
} as any;
const result = addElementToBlock(survey, "block-2", duplicateElement);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toContain("already exists");
}
});
test("should return error for duplicate element ID within same block", () => {
const survey = createMockSurvey();
const duplicateElement = {
id: "elem-1", // Already exists in block-1
type: TSurveyElementTypeEnum.Rating,
headline: { default: "Duplicate in same block" },
required: false,
range: 5,
scale: "star",
} as any;
// Try to add to the same block where elem-1 already exists
const result = addElementToBlock(survey, "block-1", duplicateElement);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toContain("already exists");
}
});
test("should return error for non-existent block", () => {
const survey = createMockSurvey();
const element = {
id: "elem-new",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Question" },
required: false,
inputType: "text",
} as any;
const result = addElementToBlock(survey, "non-existent", element);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toContain("not found");
}
});
});
describe("updateElementInBlock", () => {
test("should update element attributes", () => {
const survey = createMockSurvey();
const result = updateElementInBlock(survey, "block-1", "elem-1", {
headline: { default: "Updated Question" },
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.blocks![0].elements[0].headline.default).toBe("Updated Question");
}
});
test("should allow updating element ID to a unique ID", () => {
const survey = createMockSurvey();
const result = updateElementInBlock(survey, "block-1", "elem-1", {
id: "elem-new-id",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.blocks![0].elements[0].id).toBe("elem-new-id");
}
});
test("should return error when updating element ID to duplicate within same block", () => {
const survey = createMockSurvey();
const result = updateElementInBlock(survey, "block-1", "elem-1", {
id: "elem-2", // elem-2 already exists in block-1
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toContain("already exists");
}
});
test("should return error when updating element ID to duplicate in another block", () => {
const survey = createMockSurvey();
const result = updateElementInBlock(survey, "block-1", "elem-1", {
id: "elem-3", // elem-3 exists in block-2
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toContain("already exists");
}
});
test("should return error for non-existent element", () => {
const survey = createMockSurvey();
const result = updateElementInBlock(survey, "block-1", "non-existent", {
headline: { default: "Updated" },
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toContain("not found");
}
});
});
describe("deleteElementFromBlock", () => {
test("should delete element from block", () => {
const survey = createMockSurvey();
const result = deleteElementFromBlock(survey, "block-1", "elem-2");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.blocks[0].elements.length).toBe(1);
expect(result.data.blocks[0].elements[0].id).toBe("elem-1");
}
});
test("should return error for non-existent element", () => {
const survey = createMockSurvey();
const result = deleteElementFromBlock(survey, "block-1", "non-existent");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toContain("not found");
}
});
});
describe("duplicateElementInBlock", () => {
test("should duplicate element with new ID", () => {
const survey = createMockSurvey();
const result = duplicateElementInBlock(survey, "block-1", "elem-1");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.blocks[0].elements.length).toBe(3);
const duplicated = result.data.blocks![0].elements[1];
expect(duplicated.id).not.toBe("elem-1");
expect(duplicated.isDraft).toBe(true);
expect(duplicated.headline.default).toBe("Question 1");
}
});
test("should return error for non-existent element", () => {
const survey = createMockSurvey();
const result = duplicateElementInBlock(survey, "block-1", "non-existent");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toContain("not found");
}
});
});
});

View File

@@ -0,0 +1,390 @@
import { createId } from "@paralleldrive/cuid2";
import { TFunction } from "i18next";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
// ============================================
// UTILITY FUNCTIONS
// ============================================
/**
* Checks if an element ID is unique across all blocks
* @param elementId - The element ID to check
* @param blocks - Array of all blocks in the survey
* @returns true if the element ID is unique, false otherwise
*/
export const isElementIdUnique = (elementId: string, blocks: TSurveyBlock[]): boolean => {
for (const block of blocks) {
if (block.elements.some((e) => e.id === elementId)) {
return false;
}
}
return true;
};
// ============================================
// BLOCK OPERATIONS
// ============================================
/**
* Adds a new block to the survey. Always generates a new CUID for the block ID to prevent conflicts
* @param survey - The survey to add the block to
* @param block - Block data (without id, which is auto-generated)
* @param index - Optional index to insert the block at (appends if not provided)
* @returns Result with updated survey or Error
*/
export const addBlock = (
t: TFunction,
survey: TSurvey,
block: Omit<Partial<TSurveyBlock>, "id">,
index?: number
): Result<TSurvey, Error> => {
const updatedSurvey = { ...survey };
const blocks = [...(survey.blocks || [])];
const newBlock: TSurveyBlock = {
...block,
id: createId(),
name: block.name || t("environments.surveys.edit.untitled_block"),
elements: block.elements || [],
};
if (index === undefined) {
blocks.push(newBlock);
} else {
if (index < 0 || index > blocks.length) {
return err(new Error(`Invalid index ${index}. Must be between 0 and ${blocks.length}`));
}
blocks.splice(index, 0, newBlock);
}
updatedSurvey.blocks = blocks;
return ok(updatedSurvey);
};
/**
* Updates an existing block with new attributes
* @param survey - The survey containing the block
* @param blockId - The CUID of the block to update
* @param updatedAttributes - Partial block object with fields to update
* @returns Result with updated survey or Error
*/
export const updateBlock = (
survey: TSurvey,
blockId: string,
updatedAttributes: Omit<Partial<TSurveyBlock>, "id">
): Result<TSurvey, Error> => {
// id is not allowed from the types but this will also prevent the error during runtime
if ("id" in updatedAttributes) {
return err(new Error("Block ID cannot be updated"));
}
const blocks = [...(survey.blocks || [])];
const blockIndex = blocks.findIndex((b) => b.id === blockId);
if (blockIndex === -1) {
return err(new Error(`Block with ID "${blockId}" not found`));
}
blocks[blockIndex] = {
...blocks[blockIndex],
...updatedAttributes,
};
return ok({
...survey,
blocks,
});
};
/**
* Deletes a block from the survey
* @param survey - The survey containing the block
* @param blockId - The CUID of the block to delete
* @returns Result with updated survey or Error
*/
export const deleteBlock = (survey: TSurvey, blockId: string): Result<TSurvey, Error> => {
const filteredBlocks = survey.blocks?.filter((b) => b.id !== blockId) || [];
if (filteredBlocks.length === survey.blocks?.length) {
return err(new Error(`Block with ID "${blockId}" not found`));
}
return ok({
...survey,
blocks: filteredBlocks,
});
};
/**
* Duplicates a block with new IDs for the block and all elements
* Note: Logic is cleared because it would reference old block/element IDs.
* mapping old element IDs to new ones and updating jumpToBlock targets.
* @param survey - The survey containing the block
* @param blockId - The CUID of the block to duplicate
* @returns Result with updated survey or Error
*/
export const duplicateBlock = (survey: TSurvey, blockId: string): Result<TSurvey, Error> => {
const blocks = survey.blocks || [];
const blockIndex = blocks.findIndex((b) => b.id === blockId);
if (blockIndex === -1) {
return err(new Error(`Block with ID "${blockId}" not found`));
}
const blockToDuplicate = blocks[blockIndex];
// Deep clone the block to avoid any reference issues
const duplicatedBlock: TSurveyBlock = structuredClone(blockToDuplicate);
// Assign new IDs
duplicatedBlock.id = createId();
duplicatedBlock.name = `${blockToDuplicate.name} (copy)`;
// Generate new element IDs to avoid conflicts
duplicatedBlock.elements = duplicatedBlock.elements.map((element) => ({
...element,
id: createId(),
isDraft: true,
}));
// Clear logic since it references old block/element IDs
// In the future, we could map these references to the new IDs
duplicatedBlock.logic = undefined;
duplicatedBlock.logicFallback = undefined;
const updatedBlocks = [...blocks];
updatedBlocks.splice(blockIndex + 1, 0, duplicatedBlock);
return ok({
...survey,
blocks: updatedBlocks,
});
};
/**
* Moves a block up or down in the survey
* @param survey - The survey containing the block
* @param blockId - The CUID of the block to move
* @param direction - Direction to move ("up" or "down")
* @returns Result with updated survey (or unchanged if at boundary) or Error
*/
export const moveBlock = (
survey: TSurvey,
blockId: string,
direction: "up" | "down"
): Result<TSurvey, Error> => {
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<TSurvey, Error> => {
const blocks = [...(survey.blocks || [])];
const blockIndex = blocks.findIndex((b) => b.id === blockId);
if (blockIndex === -1) {
return err(new Error(`Block with ID "${blockId}" not found`));
}
// Validate element ID is unique across all blocks (including the target block)
if (!isElementIdUnique(element.id, blocks)) {
return err(new Error(`Element ID "${element.id}" already exists`));
}
const block = { ...blocks[blockIndex] };
const elements = [...block.elements];
const elementWithDraft = { ...element, isDraft: true };
if (index === undefined) {
elements.push(elementWithDraft);
} else {
if (index < 0 || index > elements.length) {
return err(new Error(`Invalid index ${index}. Must be between 0 and ${elements.length}`));
}
elements.splice(index, 0, elementWithDraft);
}
block.elements = elements;
blocks[blockIndex] = block;
return ok({
...survey,
blocks,
});
};
/**
* Updates an existing element in a block
* @param survey - The survey containing the block
* @param blockId - The CUID of the block containing the element
* @param elementId - The ID of the element to update
* @param updatedAttributes - Partial element object with fields to update
* @returns Result with updated survey or Error
*/
export const updateElementInBlock = (
survey: TSurvey,
blockId: string,
elementId: string,
updatedAttributes: Partial<TSurveyElement>
): Result<TSurvey, Error> => {
const blocks = [...(survey.blocks || [])];
const blockIndex = blocks.findIndex((b) => b.id === blockId);
if (blockIndex === -1) {
return err(new Error(`Block with ID "${blockId}" not found`));
}
const block = { ...blocks[blockIndex] };
const elements = [...block.elements];
const elementIndex = elements.findIndex((e) => e.id === elementId);
if (elementIndex === -1) {
return err(new Error(`Element with ID "${elementId}" not found in block "${blockId}"`));
}
// If changing the element ID, validate the new ID is unique across all blocks
if (updatedAttributes.id && updatedAttributes.id !== elementId) {
if (!isElementIdUnique(updatedAttributes.id, blocks)) {
return err(new Error(`Element ID "${updatedAttributes.id}" already exists`));
}
}
elements[elementIndex] = {
...elements[elementIndex],
...updatedAttributes,
} as TSurveyElement;
block.elements = elements;
blocks[blockIndex] = block;
return ok({
...survey,
blocks,
});
};
/**
* Deletes an element from a block
* @param survey - The survey containing the block
* @param blockId - The CUID of the block containing the element
* @param elementId - The ID of the element to delete
* @returns Result with updated survey or Error
*/
export const deleteElementFromBlock = (
survey: TSurvey,
blockId: string,
elementId: string
): Result<TSurvey, Error> => {
const blocks = [...(survey.blocks || [])];
const blockIndex = blocks.findIndex((b) => b.id === blockId);
if (blockIndex === -1) {
return err(new Error(`Block with ID "${blockId}" not found`));
}
const block = { ...blocks[blockIndex] };
const originalLength = block.elements.length;
block.elements = block.elements.filter((e) => e.id !== elementId);
if (block.elements.length === originalLength) {
return err(new Error(`Element with ID "${elementId}" not found in block "${blockId}"`));
}
blocks[blockIndex] = block;
return ok({
...survey,
blocks,
});
};
/**
* Duplicates an element within a block
* Generates a new element ID with CUID
* Sets isDraft: true on the duplicated element
* @param survey - The survey containing the block
* @param blockId - The CUID of the block containing the element
* @param elementId - The ID of the element to duplicate
* @returns Result with updated survey or Error
*/
export const duplicateElementInBlock = (
survey: TSurvey,
blockId: string,
elementId: string
): Result<TSurvey, Error> => {
const blocks = [...(survey.blocks || [])];
const blockIndex = blocks.findIndex((b) => b.id === blockId);
if (blockIndex === -1) {
return err(new Error(`Block with ID "${blockId}" not found`));
}
const block = { ...blocks[blockIndex] };
const elements = [...block.elements];
const elementIndex = elements.findIndex((e) => e.id === elementId);
if (elementIndex === -1) {
return err(new Error(`Element with ID "${elementId}" not found in block "${blockId}"`));
}
const elementToDuplicate = elements[elementIndex];
// Deep clone the element to avoid any reference issues
const duplicatedElement: TSurveyElement = structuredClone(elementToDuplicate);
duplicatedElement.id = createId();
duplicatedElement.isDraft = true;
elements.splice(elementIndex + 1, 0, duplicatedElement);
block.elements = elements;
blocks[blockIndex] = block;
return ok({
...survey,
blocks,
});
};

View File

@@ -4,7 +4,7 @@ import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSegment, ZSegmentFilters } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { checkForInvalidImagesInQuestions } from "@/lib/survey/utils";
import { checkForInvalidImagesInQuestions, validateMediaAndPrepareBlocks } from "@/lib/survey/utils";
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { getActionClasses } from "@/modules/survey/lib/action-class";
import { getOrganizationAIKeys, getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
@@ -27,6 +27,11 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
checkForInvalidImagesInQuestions(questions);
// Validate and prepare blocks for persistence
if (updatedSurvey.blocks && updatedSurvey.blocks.length > 0) {
data.blocks = validateMediaAndPrepareBlocks(updatedSurvey.blocks);
}
if (languages) {
// Process languages update logic here
// Extract currentLanguageIds and updatedLanguageIds

View File

@@ -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<typeof ZJsUserIdentifyInput>;
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<typeof ZJsConfig>;
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<typeof ZJsConfigUpdateInput>;
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<typeof ZJsConfigInput>;
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<typeof ZJsContactsUpdateAttributeInput>;
export type TJsPeopleUserIdInput = z.infer<typeof ZJsPeopleUserIdInput>;

View File

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