mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-04 04:40:37 -06:00
feat(blocks): add editor utilities, validation, and unit tests for bl… (#6768)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1619,6 +1619,7 @@
|
||||
"unlock_targeting_title": "上位プランでターゲティングをアンロック",
|
||||
"unsaved_changes_warning": "フォームに未保存の変更があります。離れる前に保存しますか?",
|
||||
"until_they_submit_a_response": "回答を送信するまで",
|
||||
"untitled_block": "無題のブロック",
|
||||
"upgrade_notice_description": "多言語フォームを作成し、さらに多くの機能をアンロック",
|
||||
"upgrade_notice_title": "上位プランで多言語フォームをアンロック",
|
||||
"upload": "アップロード",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1619,6 +1619,7 @@
|
||||
"unlock_targeting_title": "通过 更 高级 划解锁 定位",
|
||||
"unsaved_changes_warning": "您在调查中有未保存的更改。离开前是否要保存?",
|
||||
"until_they_submit_a_response": "直到 他们 提交 回复",
|
||||
"untitled_block": "未命名区块",
|
||||
"upgrade_notice_description": "创建 多语言 调查 并 解锁 更多 功能",
|
||||
"upgrade_notice_title": "解锁 更高 计划 中 的 多语言 调查",
|
||||
"upload": "上传",
|
||||
|
||||
@@ -1619,6 +1619,7 @@
|
||||
"unlock_targeting_title": "使用更高等級的方案解鎖目標設定",
|
||||
"unsaved_changes_warning": "您的問卷中有未儲存的變更。您要先儲存它們再離開嗎?",
|
||||
"until_they_submit_a_response": "直到他們提交回應",
|
||||
"untitled_block": "未命名區塊",
|
||||
"upgrade_notice_description": "建立多語言問卷並解鎖更多功能",
|
||||
"upgrade_notice_title": "使用更高等級的方案解鎖多語言問卷",
|
||||
"upload": "上傳",
|
||||
|
||||
478
apps/web/modules/survey/editor/lib/blocks.test.ts
Normal file
478
apps/web/modules/survey/editor/lib/blocks.test.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
390
apps/web/modules/survey/editor/lib/blocks.ts
Normal file
390
apps/web/modules/survey/editor/lib/blocks.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user