fixes feedback comments

This commit is contained in:
pandeymangg
2025-11-06 12:02:25 +05:30
parent f349f7199d
commit b2b97c8bed
23 changed files with 180 additions and 175 deletions

View File

@@ -5,6 +5,7 @@ import { TSurvey, TSurveyRecallItem } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import { formatDateWithOrdinal, isValidDateString } from "./datetime";
export interface fallbacks {
@@ -61,7 +62,7 @@ const getRecallItemLabel = <T extends TSurvey>(
const isHiddenField = survey.hiddenFields.fieldIds?.includes(recallItemId);
if (isHiddenField) return recallItemId;
const questions = survey.blocks.flatMap((b) => b.elements);
const questions = getQuestionsFromBlocks(survey.blocks);
const surveyQuestion = questions.find((question) => question.id === recallItemId);
if (surveyQuestion) {
const headline = getLocalizedValue(surveyQuestion.headline, languageCode);
@@ -131,7 +132,7 @@ export const checkForEmptyFallBackValue = (survey: TSurvey, language: string): T
return recalls?.some((recall) => !extractFallbackValue(recall));
};
const questions = survey.blocks.flatMap((b) => b.elements);
const questions = getQuestionsFromBlocks(survey.blocks);
for (const question of questions) {
if (
doesTextHaveRecall(getLocalizedValue(question.headline, language)) ||
@@ -146,7 +147,7 @@ export const checkForEmptyFallBackValue = (survey: TSurvey, language: string): T
// Processes each question in a survey to ensure headlines are formatted correctly for recall and return the modified survey.
export const replaceHeadlineRecall = <T extends TSurvey>(survey: T, language: string): T => {
const modifiedSurvey = structuredClone(survey);
const questions = modifiedSurvey.blocks.flatMap((b) => b.elements);
const questions = getQuestionsFromBlocks(modifiedSurvey.blocks);
questions.forEach((question) => {
question.headline = recallToHeadline(question.headline, modifiedSurvey, false, language);
});
@@ -161,7 +162,7 @@ export const getRecallItems = (text: string, survey: TSurvey, languageCode: stri
let recallItems: TSurveyRecallItem[] = [];
ids.forEach((recallItemId) => {
const isHiddenField = survey.hiddenFields.fieldIds?.includes(recallItemId);
const questions = survey.blocks.flatMap((b) => b.elements);
const questions = getQuestionsFromBlocks(survey.blocks);
const isSurveyQuestion = questions.find((question) => question.id === recallItemId);
const isVariable = survey.variables.find((variable) => variable.id === recallItemId);

View File

@@ -9,6 +9,7 @@ import { getTextContent, isValidHTML } from "@formbricks/types/surveys/validatio
import { TUserLocale } from "@formbricks/types/user";
import { md } from "@/lib/markdownIt";
import { recallToHeadline } from "@/lib/utils/recall";
import { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import { isLabelValidForAllLanguages } from "@/modules/survey/editor/lib/validation";
import { Editor } from "@/modules/ui/components/editor";
import { LanguageIndicator } from "./language-indicator";
@@ -63,10 +64,7 @@ export function LocalizedEditor({
isExternalUrlsAllowed,
}: Readonly<LocalizedEditorProps>) {
// Derive questions from blocks for migrated surveys
const questions = useMemo(
() => localSurvey.blocks.flatMap((block) => block.elements),
[localSurvey.blocks]
);
const questions = useMemo(() => getQuestionsFromBlocks(localSurvey.blocks), [localSurvey.blocks]);
const { t } = useTranslation();
const isInComplete = useMemo(

View File

@@ -1,10 +1,10 @@
"use client";
import { Language } from "@prisma/client";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import { LanguageToggle } from "./language-toggle";
interface SecondaryLanguageSelectProps {
@@ -33,9 +33,7 @@ export function SecondaryLanguageSelect({
);
};
const questions = useMemo(() => {
return localSurvey.blocks.flatMap((block) => block.elements);
}, [localSurvey.blocks]);
const questions = getQuestionsFromBlocks(localSurvey.blocks);
return (
<div className="space-y-2">

View File

@@ -20,6 +20,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createQuotaAction, updateQuotaAction } from "@/modules/ee/quotas/actions";
import { EndingCardSelector } from "@/modules/ee/quotas/components/ending-card-selector";
import { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import { getDefaultOperatorForQuestion } from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
@@ -81,9 +82,7 @@ export const QuotaModal = ({
const [openConfirmationModal, setOpenConfirmationModal] = useState(false);
const [openConfirmChangesInInclusionCriteria, setOpenConfirmChangesInInclusionCriteria] = useState(false);
const questions = useMemo(() => {
return survey.blocks.flatMap((block) => block.elements);
}, [survey.blocks]);
const questions = useMemo(() => getQuestionsFromBlocks(survey.blocks), [survey.blocks]);
const defaultValues = useMemo(() => {
const firstQuestion = questions[0];

View File

@@ -13,7 +13,7 @@ import { render } from "@react-email/render";
import { TFunction } from "i18next";
import { CalendarDaysIcon, UploadIcon } from "lucide-react";
import React from "react";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { type TSurvey, type TSurveyStyling } from "@formbricks/types/surveys/types";
import { cn } from "@/lib/cn";
import { WEBAPP_URL } from "@/lib/constants";
@@ -22,6 +22,7 @@ import { COLOR_DEFAULTS } from "@/lib/styling/constants";
import { isLight, mixColor } from "@/lib/utils/colors";
import { parseRecallInfo } from "@/lib/utils/recall";
import { RatingSmiley } from "@/modules/analysis/components/RatingSmiley";
import { findElementLocation, getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import { getNPSOptionColor, getRatingNumberOptionColor } from "../lib/utils";
import { QuestionHeader } from "./email-question-header";
@@ -78,24 +79,17 @@ export async function PreviewEmailTemplate({
const url = `${surveyUrl}?preview=true`;
const urlWithPrefilling = `${surveyUrl}?preview=true&skipPrefilled=true&`;
const defaultLanguageCode = "default";
// Derive questions from blocks
const questions = survey.blocks.flatMap((block) => block.elements);
const firstQuestion = questions[0] as TSurveyElement;
const questions = getQuestionsFromBlocks(survey.blocks);
const firstQuestion = questions[0];
const { block } = findElementLocation(survey, firstQuestion.id);
const headline = parseRecallInfo(getLocalizedValue(firstQuestion.headline, defaultLanguageCode));
const subheader = parseRecallInfo(getLocalizedValue(firstQuestion.subheader, defaultLanguageCode));
const brandColor = styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor;
const getButtonLabel = (survey: TSurvey, defaultLanguageCode: string) => {
const ctaQuestionBlock = survey.blocks.find((block) =>
block.elements.some((element) => element.type === TSurveyElementTypeEnum.CTA)
);
if (ctaQuestionBlock) {
return getLocalizedValue(ctaQuestionBlock.buttonLabel, defaultLanguageCode);
}
return t("common.next");
};
switch (firstQuestion.type) {
case TSurveyElementTypeEnum.OpenText:
return (
@@ -201,7 +195,7 @@ export async function PreviewEmailTemplate({
isLight(brandColor) ? "text-black" : "text-white"
)}
href={`${urlWithPrefilling}${firstQuestion.id}=clicked`}>
{getButtonLabel(survey, defaultLanguageCode)}
{getLocalizedValue(block?.buttonLabel, defaultLanguageCode)}
</EmailButton>
</Container>
<EmailFooter />

View File

@@ -18,6 +18,7 @@ import { useTranslation } from "react-i18next";
import { TSurveyElement, TSurveyElementId, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyHiddenFields, TSurveyRecallItem } from "@formbricks/types/surveys/types";
import { getTextContentWithRecallTruncated } from "@/lib/utils/recall";
import { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import {
DropdownMenu,
DropdownMenuContent,
@@ -70,6 +71,8 @@ export const RecallItemSelect = ({
);
};
const questions = useMemo(() => getQuestionsFromBlocks(localSurvey.blocks), [localSurvey.blocks]);
const recallItemIds = useMemo(() => {
return recallItems.map((recallItem) => recallItem.id);
}, [recallItems]);
@@ -109,9 +112,6 @@ export const RecallItemSelect = ({
const isWelcomeCard = questionId === "start";
if (isWelcomeCard) return [];
// Derive questions from blocks or fall back to legacy questions array
const questions = localSurvey.blocks.flatMap((block) => block.elements);
const isEndingCard = !questions.map((question) => question.id).includes(questionId);
const idx = isEndingCard
? questions.length
@@ -128,7 +128,7 @@ export const RecallItemSelect = ({
});
return filteredQuestions;
}, [localSurvey.blocks, questionId, recallItemIds, selectedLanguageCode]);
}, [questionId, questions, recallItemIds, selectedLanguageCode]);
const filteredRecallItems: TSurveyRecallItem[] = useMemo(() => {
return [...surveyQuestionRecallItems, ...hiddenFieldRecallItems, ...variableRecallItems].filter(
@@ -144,8 +144,6 @@ export const RecallItemSelect = ({
const getRecallItemIcon = (recallItem: TSurveyRecallItem) => {
switch (recallItem.type) {
case "question":
// Derive questions from blocks or fall back to legacy questions array
const questions = localSurvey.blocks.flatMap((block) => block.elements);
const question = questions.find((question) => question.id === recallItem.id);
if (question) {
return questionIconMapping[question?.type as keyof typeof questionIconMapping];

View File

@@ -18,6 +18,7 @@ import {
} from "@/lib/utils/recall";
import { FallbackInput } from "@/modules/survey/components/question-form-input/components/fallback-input";
import { RecallItemSelect } from "@/modules/survey/components/question-form-input/components/recall-item-select";
import { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import { Button } from "@/modules/ui/components/button";
interface RecallWrapperRenderProps {
@@ -189,7 +190,7 @@ export const RecallWrapper = ({
const info = extractRecallInfo(recallItem.label);
if (info) {
const recallItemId = extractId(info);
const questions = localSurvey.blocks.flatMap((block) => block.elements);
const questions = getQuestionsFromBlocks(localSurvey.blocks);
const recallQuestion = questions.find((q) => q.id === recallItemId);
if (recallQuestion) {
// replace nested recall with "___"

View File

@@ -21,6 +21,7 @@ import { recallToHeadline } from "@/lib/utils/recall";
import { LocalizedEditor } from "@/modules/ee/multi-language-surveys/components/localized-editor";
import { MultiLangWrapper } from "@/modules/survey/components/question-form-input/components/multi-lang-wrapper";
import { RecallWrapper } from "@/modules/survey/components/question-form-input/components/recall-wrapper";
import { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import { Button } from "@/modules/ui/components/button";
import { FileInput } from "@/modules/ui/components/file-input";
import { Input } from "@/modules/ui/components/input";
@@ -92,7 +93,8 @@ export const QuestionFormInput = ({
const defaultLanguageCode =
localSurvey.languages.filter((lang) => lang.default)[0]?.language.code ?? "default";
const usedLanguageCode = selectedLanguageCode === defaultLanguageCode ? "default" : selectedLanguageCode;
const questions = localSurvey.blocks.flatMap((block) => block.elements);
const questions = useMemo(() => getQuestionsFromBlocks(localSurvey.blocks), [localSurvey.blocks]);
const question: TSurveyElement = questions[questionIdx];
const isChoice = id.includes("choice");
@@ -168,7 +170,7 @@ export const QuestionFormInput = ({
const [text, setText] = useState(elementText);
const [showImageUploader, setShowImageUploader] = useState<boolean>(
determineImageUploaderVisibility(questionIdx, localSurvey)
determineImageUploaderVisibility(questionIdx, questions)
);
const highlightContainerRef = useRef<HTMLInputElement>(null);

View File

@@ -2,12 +2,8 @@ import "@testing-library/jest-dom/vitest";
import { TFunction } from "i18next";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { type TI18nString } from "@formbricks/types/i18n";
import {
TSurvey,
TSurveyMultipleChoiceQuestion,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { createI18nString } from "@/lib/i18n/utils";
import * as i18nUtils from "@/lib/i18n/utils";
import {
@@ -48,7 +44,7 @@ describe("utils", () => {
describe("getChoiceLabel", () => {
test("returns the choice label from a question", () => {
const surveyLanguageCodes = ["en"];
const choiceQuestion: TSurveyMultipleChoiceQuestion = {
const choiceQuestion = {
id: "q1",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: createI18nString("Question?", surveyLanguageCodes),
@@ -57,7 +53,7 @@ describe("utils", () => {
{ id: "c1", label: createI18nString("Choice 1", surveyLanguageCodes) },
{ id: "c2", label: createI18nString("Choice 2", surveyLanguageCodes) },
],
};
} as unknown as TSurveyElement;
const result = getChoiceLabel(choiceQuestion, 1, surveyLanguageCodes);
expect(result).toEqual(createI18nString("Choice 2", surveyLanguageCodes));
@@ -65,13 +61,13 @@ describe("utils", () => {
test("returns empty i18n string when choice doesn't exist", () => {
const surveyLanguageCodes = ["en"];
const choiceQuestion: TSurveyMultipleChoiceQuestion = {
const choiceQuestion = {
id: "q1",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: createI18nString("Question?", surveyLanguageCodes),
required: true,
choices: [],
};
} as unknown as TSurveyElement;
const result = getChoiceLabel(choiceQuestion, 0, surveyLanguageCodes);
expect(result).toEqual(createI18nString("", surveyLanguageCodes));
@@ -94,7 +90,7 @@ describe("utils", () => {
{ id: "col-1", label: createI18nString("Column 1", surveyLanguageCodes) },
{ id: "col-2", label: createI18nString("Column 2", surveyLanguageCodes) },
],
} as unknown as TSurveyQuestion;
} as unknown as TSurveyElement;
const result = getMatrixLabel(matrixQuestion, 1, surveyLanguageCodes, "row");
expect(result).toEqual(createI18nString("Row 2", surveyLanguageCodes));
@@ -115,7 +111,7 @@ describe("utils", () => {
{ id: "col-1", label: createI18nString("Column 1", surveyLanguageCodes) },
{ id: "col-2", label: createI18nString("Column 2", surveyLanguageCodes) },
],
} as unknown as TSurveyQuestion;
} as unknown as TSurveyElement;
const result = getMatrixLabel(matrixQuestion, 0, surveyLanguageCodes, "column");
expect(result).toEqual(createI18nString("Column 1", surveyLanguageCodes));
@@ -130,7 +126,7 @@ describe("utils", () => {
required: true,
rows: [],
columns: [],
} as unknown as TSurveyQuestion;
} as unknown as TSurveyElement;
const result = getMatrixLabel(matrixQuestion, 0, surveyLanguageCodes, "row");
expect(result).toEqual(createI18nString("", surveyLanguageCodes));
@@ -264,25 +260,7 @@ describe("utils", () => {
describe("determineImageUploaderVisibility", () => {
test("returns false for welcome card", () => {
const survey = {
id: "survey1",
name: "Test Survey",
createdAt: new Date(),
updatedAt: new Date(),
status: "draft",
questions: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
styling: {},
environmentId: "env1",
type: "app",
triggers: [],
recontactDays: null,
endings: [],
delay: 0,
pin: null,
} as unknown as TSurvey;
const result = determineImageUploaderVisibility(-1, survey);
const result = determineImageUploaderVisibility(-1, []);
expect(result).toBe(false);
});
@@ -319,7 +297,7 @@ describe("utils", () => {
pin: null,
} as unknown as TSurvey;
const result = determineImageUploaderVisibility(0, survey);
const result = determineImageUploaderVisibility(0, survey.blocks[0].elements);
expect(result).toBe(true);
});
@@ -356,7 +334,7 @@ describe("utils", () => {
pin: null,
} as unknown as TSurvey;
const result = determineImageUploaderVisibility(0, survey);
const result = determineImageUploaderVisibility(0, survey.blocks[0].elements);
expect(result).toBe(true);
});
@@ -392,7 +370,7 @@ describe("utils", () => {
pin: null,
} as unknown as TSurvey;
const result = determineImageUploaderVisibility(0, survey);
const result = determineImageUploaderVisibility(0, survey.blocks[0].elements);
expect(result).toBe(false);
});
});

View File

@@ -66,13 +66,12 @@ export const getEndingCardText = (
}
};
export const determineImageUploaderVisibility = (questionIdx: number, localSurvey: TSurvey) => {
export const determineImageUploaderVisibility = (questionIdx: number, questions: TSurveyElement[]) => {
switch (questionIdx) {
case -1: // Welcome Card
return false;
default: {
// Regular Survey Question - derive questions from blocks
const questions = localSurvey.blocks.flatMap((block) => block.elements);
const question = questions[questionIdx];
return (!!question && !!question.imageUrl) || (!!question && !!question.videoUrl);
}

View File

@@ -58,8 +58,9 @@ export function ConditionalLogic({
}, [localSurvey]);
// Find the parent block for this question/element to get its logic
const parentBlock = localSurvey.blocks.find((block) =>
block.elements.some((element) => element.id === question.id)
const parentBlock = useMemo(
() => localSurvey.blocks.find((block) => block.elements.some((element) => element.id === question.id)),
[localSurvey.blocks, question.id]
);
const blockLogic = useMemo(() => parentBlock?.logic ?? [], [parentBlock?.logic]);
@@ -146,7 +147,7 @@ export function ConditionalLogic({
className="relative flex w-full grow items-start gap-2 rounded-lg border border-slate-200 bg-slate-50 p-3">
<LogicEditor
localSurvey={transformedSurvey}
logicItem={logicItem as TSurveyBlockLogic}
logicItem={logicItem}
updateQuestion={updateQuestion}
updateBlockLogic={updateBlockLogic}
updateBlockLogicFallback={updateBlockLogicFallback}

View File

@@ -12,6 +12,7 @@ import {
TSurveyQuestionTypeEnum,
TSurveyRedirectUrlCard,
} from "@formbricks/types/surveys/types";
import { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import {
getCXQuestionNameMap,
getQuestionDefaults,
@@ -70,7 +71,8 @@ export const EditorCardMenu = ({
return undefined;
});
const questions = survey.blocks.flatMap((block) => block.elements);
const questions = getQuestionsFromBlocks(survey.blocks);
const isDeleteDisabled =
cardType === "question" ? questions.length === 1 : survey.type === "link" && survey.endings.length === 1;

View File

@@ -9,6 +9,7 @@ import { createI18nString, extractLanguageCodes, getLocalizedValue } from "@/lib
import { headlineToRecall, recallToHeadline } from "@/lib/utils/recall";
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
import { RecallWrapper } from "@/modules/survey/components/question-form-input/components/recall-wrapper";
import { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
@@ -43,7 +44,7 @@ export const EndScreenForm = ({
const inputRef = useRef<HTMLInputElement>(null);
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
const questions = localSurvey.blocks.flatMap((block) => block.elements);
const questions = getQuestionsFromBlocks(localSurvey.blocks);
const [showEndingCardCTA, setshowEndingCardCTA] = useState<boolean>(
endingCard.type === "endScreen" &&

View File

@@ -3,7 +3,7 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import * as Collapsible from "@radix-ui/react-collapsible";
import { EyeOff } from "lucide-react";
import { useState } from "react";
import { useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TSurveyQuota } from "@formbricks/types/quota";
@@ -11,6 +11,7 @@ import { TSurvey, TSurveyHiddenFields, TSurveyQuestionId } from "@formbricks/typ
import { validateId } from "@formbricks/types/surveys/validation";
import { cn } from "@/lib/cn";
import { extractRecallInfo } from "@/lib/utils/recall";
import { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import { findHiddenFieldUsedInLogic, isUsedInQuota, isUsedInRecall } from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
@@ -44,6 +45,8 @@ export const HiddenFieldsCard = ({
}
};
const questions = useMemo(() => getQuestionsFromBlocks(localSurvey.blocks), [localSurvey.blocks]);
const updateSurvey = (data: TSurveyHiddenFields, currentFieldId?: string) => {
let updatedSurvey = { ...localSurvey };
@@ -97,7 +100,8 @@ export const HiddenFieldsCard = ({
);
return;
}
const totalQuestions = localSurvey.blocks.flatMap((b) => b.elements).length;
const totalQuestions = questions.length;
if (recallQuestionIdx === totalQuestions) {
toast.error(
t("environments.surveys.edit.hidden_field_used_in_recall_ending_card", { hiddenField: fieldId })
@@ -196,8 +200,7 @@ export const HiddenFieldsCard = ({
className="mt-5"
onSubmit={(e) => {
e.preventDefault();
const existingElements = localSurvey.blocks.flatMap((b) => b.elements);
const existingQuestionIds = existingElements.map((question) => question.id);
const existingQuestionIds = questions.map((question) => question.id);
const existingEndingCardIds = localSurvey.endings.map((ending) => ending.id);
const existingHiddenFieldIds = localSurvey.hiddenFields.fieldIds ?? [];
const validateIdError = validateId(

View File

@@ -10,6 +10,7 @@ import { getTextContent } from "@formbricks/types/surveys/validation";
import { recallToHeadline } from "@/lib/utils/recall";
import { LogicEditorActions } from "@/modules/survey/editor/components/logic-editor-actions";
import { LogicEditorConditions } from "@/modules/survey/editor/components/logic-editor-conditions";
import { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import { getQuestionIconMap } from "@/modules/survey/lib/questions";
import {
Select,
@@ -60,7 +61,7 @@ export function LogicEditor({
}[] = [];
// Derive questions from blocks
const allQuestions = localSurvey.blocks.flatMap((b) => b.elements);
const allQuestions = getQuestionsFromBlocks(localSurvey.blocks);
const blocks = localSurvey.blocks;
// Track which blocks we've already added to avoid duplicates when a block has multiple elements

View File

@@ -7,6 +7,7 @@ import { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
import { TSurvey, TSurveyQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { QuestionCard } from "@/modules/survey/editor/components/question-card";
import { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
interface QuestionsDraggableProps {
localSurvey: TSurvey;
@@ -64,9 +65,7 @@ export const QuestionsDroppable = ({
const [parent] = useAutoAnimate();
// Derive questions from blocks for display
const questions = useMemo(() => {
return localSurvey.blocks.flatMap((block) => block.elements);
}, [localSurvey.blocks]);
const questions = useMemo(() => getQuestionsFromBlocks(localSurvey.blocks), [localSurvey.blocks]);
return (
<div className="group mb-5 flex w-full flex-col gap-5" ref={parent}>

View File

@@ -39,6 +39,8 @@ import {
addBlock,
deleteBlock,
duplicateBlock,
findElementLocation,
getQuestionsFromBlocks,
moveBlock,
updateElementInBlock,
} from "@/modules/survey/editor/lib/blocks";
@@ -96,9 +98,7 @@ export const QuestionsView = ({
const { t } = useTranslation();
// Derive questions from blocks for display
const questions = useMemo(() => {
return localSurvey.blocks.flatMap((block) => block.elements);
}, [localSurvey.blocks]);
const questions = useMemo(() => getQuestionsFromBlocks(localSurvey.blocks), [localSurvey.blocks]);
const internalQuestionIdMap = useMemo(() => {
return questions.reduce((acc, question) => {
@@ -109,20 +109,6 @@ export const QuestionsView = ({
const surveyLanguages = localSurvey.languages;
const findElementLocation = (elementId: string) => {
const blocks = localSurvey.blocks;
for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
const block = blocks[blockIndex];
const elementIndex = block.elements.findIndex((e) => e.id === elementId);
if (elementIndex !== -1) {
return { blockId: block.id, blockIndex, elementIndex };
}
}
return { blockId: null, blockIndex: -1, elementIndex: -1 };
};
const getQuestionIdFromBlockId = (block: TSurveyBlock): string => block.elements[0].id;
const getBlockName = (index: number): string => {
@@ -279,7 +265,7 @@ export const QuestionsView = ({
const question = questions[questionIdx];
if (!question) return;
const { blockId, blockIndex } = findElementLocation(question.id);
const { blockId, blockIndex } = findElementLocation(localSurvey, question.id);
if (!blockId || blockIndex === -1) return;
let updatedSurvey = { ...localSurvey };
@@ -368,7 +354,7 @@ export const QuestionsView = ({
const question = questions[questionIdx];
if (!question) return;
const { blockIndex } = findElementLocation(question.id);
const { blockIndex } = findElementLocation(localSurvey, question.id);
if (blockIndex === -1) return;
setLocalSurvey((prevSurvey) => {
@@ -386,7 +372,7 @@ export const QuestionsView = ({
const question = questions[questionIdx];
if (!question) return;
const { blockIndex } = findElementLocation(question.id);
const { blockIndex } = findElementLocation(localSurvey, question.id);
if (blockIndex === -1) return;
setLocalSurvey((prevSurvey) => {
@@ -482,7 +468,7 @@ export const QuestionsView = ({
}));
// Find and delete the block containing this question
const { blockId } = findElementLocation(questionId);
const { blockId } = findElementLocation(localSurvey, questionId);
if (!blockId) return;
const result = deleteBlock(updatedSurvey, blockId);
@@ -511,7 +497,7 @@ export const QuestionsView = ({
const question = questions[questionIdx];
if (!question) return;
const { blockId } = findElementLocation(question.id);
const { blockId } = findElementLocation(localSurvey, question.id);
if (!blockId) return;
const result = duplicateBlock(localSurvey, blockId);
@@ -523,7 +509,7 @@ export const QuestionsView = ({
// The duplicated block has new element IDs, find the first one
const allBlocks = result.data.blocks ?? [];
const { blockIndex } = findElementLocation(question.id);
const { blockIndex } = findElementLocation(localSurvey, question.id);
const duplicatedBlock = allBlocks[blockIndex + 1];
const newElementId = duplicatedBlock?.elements[0]?.id;
@@ -572,7 +558,7 @@ export const QuestionsView = ({
const question = questions[questionIndex];
if (!question) return;
const { blockId } = findElementLocation(question.id);
const { blockId } = findElementLocation(localSurvey, question.id);
if (!blockId) return;
const direction = up ? "up" : "down";
@@ -633,8 +619,8 @@ export const QuestionsView = ({
if (!sourceQuestion || !destQuestion) return;
const { blockIndex: sourceBlockIndex } = findElementLocation(sourceQuestion.id);
const { blockIndex: destBlockIndex } = findElementLocation(destQuestion.id);
const { blockIndex: sourceBlockIndex } = findElementLocation(localSurvey, sourceQuestion.id);
const { blockIndex: destBlockIndex } = findElementLocation(localSurvey, destQuestion.id);
if (sourceBlockIndex === -1 || destBlockIndex === -1) return;
if (sourceBlockIndex === destBlockIndex) return; // No move needed

View File

@@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TSurvey, TSurveyVariable } from "@formbricks/types/surveys/types";
import { extractRecallInfo } from "@/lib/utils/recall";
import { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import { findVariableUsedInLogic, isUsedInQuota, isUsedInRecall } from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { FormControl, FormField, FormItem, FormProvider } from "@/modules/ui/components/form";
@@ -78,7 +79,7 @@ export const SurveyVariablesCardItem = ({
// Removed auto-submit effect
const onVariableDelete = (variableToDelete: TSurveyVariable) => {
const questions = localSurvey.blocks.flatMap((block) => block.elements);
const questions = getQuestionsFromBlocks(localSurvey.blocks);
const quesIdx = findVariableUsedInLogic(localSurvey, variableToDelete.id);
if (quesIdx !== -1) {

View File

@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { validateId } from "@formbricks/types/surveys/validation";
import { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
@@ -36,7 +37,7 @@ export const UpdateQuestionId = ({
return;
}
const questions = localSurvey.blocks.flatMap((block) => block.elements);
const questions = getQuestionsFromBlocks(localSurvey.blocks);
const questionIds = questions.map((q) => q.id);
const endingCardIds = localSurvey.endings.map((e) => e.id);
const hiddenFieldIds = localSurvey.hiddenFields.fieldIds ?? [];

View File

@@ -24,6 +24,32 @@ export const isElementIdUnique = (elementId: string, blocks: TSurveyBlock[]): bo
return true;
};
/**
* Find the location of an element within the survey blocks
* @param survey - The survey object
* @param elementId - The ID of the element to find
* @returns Object containing blockId, blockIndex, elementIndex and the block
*/
export const findElementLocation = (
survey: TSurvey,
elementId: string
): { blockId: string | null; blockIndex: number; elementIndex: number; block: TSurveyBlock | null } => {
const blocks = survey.blocks;
for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
const block = blocks[blockIndex];
const elementIndex = block.elements.findIndex((e) => e.id === elementId);
if (elementIndex !== -1) {
return { blockId: block.id, blockIndex, elementIndex, block };
}
}
return { blockId: null, blockIndex: -1, elementIndex: -1, block: null };
};
export const getQuestionsFromBlocks = (blocks: TSurveyBlock[]): TSurveyElement[] =>
blocks.flatMap((block) => block.elements);
// ============================================
// BLOCK OPERATIONS
// ============================================
@@ -388,26 +414,3 @@ export const duplicateElementInBlock = (
blocks,
});
};
/**
* Find the location of an element within the survey blocks
* @param survey - The survey object
* @param elementId - The ID of the element to find
* @returns Object containing blockId, blockIndex, and elementIndex
*/
export const findElementLocation = (
survey: TSurvey,
elementId: string
): { blockId: string | null; blockIndex: number; elementIndex: number } => {
const blocks = survey.blocks;
for (let blockIndex = 0; blockIndex < blocks.length; blockIndex++) {
const block = blocks[blockIndex];
const elementIndex = block.elements.findIndex((e) => e.id === elementId);
if (elementIndex !== -1) {
return { blockId: block.id, blockIndex, elementIndex };
}
}
return { blockId: null, blockIndex: -1, elementIndex: -1 };
};

View File

@@ -16,6 +16,7 @@ import {
toggleGroupConnector,
updateCondition,
} from "@/lib/surveyLogic/utils";
import { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import {
getConditionOperatorOptions,
getConditionValueOptions,
@@ -56,7 +57,7 @@ export function createSharedConditionsFactory(
const { onConditionsChange } = updateCallbacks;
// Derive questions from blocks
const questions = survey.blocks.flatMap((block) => block.elements.map((element) => element));
const questions = getQuestionsFromBlocks(survey.blocks);
// Handles special update logic for matrix questions, setting appropriate operators and metadata
const handleMatrixQuestionUpdate = (resourceId: string, updates: Partial<TSingleCondition>): boolean => {

View File

@@ -24,6 +24,7 @@ import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { isConditionGroup } from "@/lib/surveyLogic/utils";
import { recallToHeadline } from "@/lib/utils/recall";
import { findElementLocation, getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { TComboboxGroupedOption, TComboboxOption } from "@/modules/ui/components/input-combo-box";
import { TLogicRuleOption, getLogicRules } from "./logic-rule-engine";
@@ -112,7 +113,7 @@ export const getConditionValueOptions = (
const hiddenFields = localSurvey.hiddenFields?.fieldIds ?? [];
const variables = localSurvey.variables ?? [];
// Derive questions from blocks
const questions = localSurvey.blocks.flatMap((block) => block.elements.map((element) => element));
const questions = getQuestionsFromBlocks(localSurvey.blocks);
const groupedOptions: TComboboxGroupedOption[] = [];
const questionOptions: TComboboxOption[] = [];
@@ -278,7 +279,7 @@ export const getDefaultOperatorForQuestion = (
export const getFormatLeftOperandValue = (condition: TSingleCondition, localSurvey: TSurvey): string => {
if (condition.leftOperand.type === "question") {
const questions = localSurvey.blocks.flatMap((block) => block.elements.map((element) => element));
const questions = getQuestionsFromBlocks(localSurvey.blocks);
const question = questions.find((q) => q.id === condition.leftOperand.value);
if (question && question.type === TSurveyElementTypeEnum.Matrix) {
if (condition.leftOperand?.meta?.row !== undefined) {
@@ -303,7 +304,7 @@ export const getConditionOperatorOptions = (
return getLogicRules(t).hiddenField.options;
} else if (condition.leftOperand.type === "question") {
// Derive questions from blocks
const questions = localSurvey.blocks.flatMap((block) => block.elements.map((element) => element));
const questions = getQuestionsFromBlocks(localSurvey.blocks);
const question = questions.find((question) => {
let leftOperandQuestionId = condition.leftOperand.value;
if (question.type === TSurveyElementTypeEnum.Matrix) {
@@ -349,7 +350,7 @@ export const getMatchValueProps = (
}
// Derive questions from blocks
const allQuestions = localSurvey.blocks.flatMap((block) => block.elements.map((element) => element));
const allQuestions = getQuestionsFromBlocks(localSurvey.blocks);
let questions = allQuestions.filter((_, idx) =>
typeof questionIdx === "undefined" ? true : idx <= questionIdx
);
@@ -1068,7 +1069,7 @@ export const getActionValueOptions = (
questionIdx: number,
t: TFunction
): TComboboxGroupedOption[] => {
const questions = localSurvey.blocks.flatMap((block) => block.elements.map((element) => element));
const questions = getQuestionsFromBlocks(localSurvey.blocks);
const hiddenFields = localSurvey.hiddenFields?.fieldIds ?? [];
let variables = localSurvey.variables ?? [];
const filteredQuestions = questions.filter((_, idx) => idx <= questionIdx);
@@ -1250,6 +1251,13 @@ const isUsedInRightOperand = (
};
export const findQuestionUsedInLogic = (survey: TSurvey, questionId: TSurveyQuestionId): number => {
const { block } = findElementLocation(survey, questionId);
// The parent block for this questionId was not found in the survey, while this shouldn't happen but we still have a safety check and return -1
if (!block) {
return -1;
}
const isUsedInCondition = (condition: TSingleCondition | TConditionGroup): boolean => {
if (isConditionGroup(condition)) {
// It's a TConditionGroup
@@ -1264,10 +1272,11 @@ export const findQuestionUsedInLogic = (survey: TSurvey, questionId: TSurveyQues
};
const isUsedInAction = (action: TSurveyBlockLogicAction): boolean => {
return (
// Note: jumpToBlock targets block IDs, not question IDs, so we only check requireAnswer
action.objective === "requireAnswer" && action.target === questionId
);
if (action.objective === "requireAnswer" && action.target === questionId) {
return true;
}
return action.objective === "jumpToBlock" && action.target === block.id;
};
const isUsedInLogicRule = (logicRule: TSurveyBlockLogic): boolean => {
@@ -1275,14 +1284,20 @@ export const findQuestionUsedInLogic = (survey: TSurvey, questionId: TSurveyQues
};
// Derive questions from blocks (cast as questions to access logic properties)
const questions = (survey.blocks?.flatMap((b) => b.elements) ?? []) as unknown as TSurveyQuestion[];
const questions = getQuestionsFromBlocks(survey.blocks);
return questions.findIndex(
(question) =>
question.logicFallback === questionId ||
(question.id !== questionId &&
(question.logic as unknown as TSurveyBlockLogic[])?.some(isUsedInLogicRule))
);
return questions.findIndex((question) => {
const { block } = findElementLocation(survey, question.id);
if (!block) {
return false;
}
return (
block.logicFallback === questionId ||
(question.id !== questionId && block.logic?.some(isUsedInLogicRule))
);
});
};
export const isUsedInQuota = (
@@ -1439,11 +1454,17 @@ export const findOptionUsedInLogic = (
};
// Derive questions from blocks (cast as questions to access logic properties)
const questions = (survey.blocks?.flatMap((b) => b.elements) ?? []) as unknown as TSurveyQuestion[];
const questions = getQuestionsFromBlocks(survey.blocks);
return questions.findIndex((question) =>
(question.logic as unknown as TSurveyBlockLogic[])?.some(isUsedInLogicRule)
);
return questions.findIndex((question) => {
const { block } = findElementLocation(survey, question.id);
if (!block) {
return false;
}
return block.logic?.some(isUsedInLogicRule);
});
};
export const findVariableUsedInLogic = (survey: TSurvey, variableId: string): number => {
@@ -1471,9 +1492,15 @@ export const findVariableUsedInLogic = (survey: TSurvey, variableId: string): nu
// Derive questions from blocks (cast as questions to access logic properties)
const questions = (survey.blocks?.flatMap((b) => b.elements) ?? []) as unknown as TSurveyQuestion[];
return questions.findIndex((question) =>
(question.logic as unknown as TSurveyBlockLogic[])?.some(isUsedInLogicRule)
);
return questions.findIndex((question) => {
const { block } = findElementLocation(survey, question.id);
if (!block) {
return false;
}
return block.logic?.some(isUsedInLogicRule);
});
};
export const findHiddenFieldUsedInLogic = (survey: TSurvey, hiddenFieldId: string): number => {
@@ -1496,11 +1523,17 @@ export const findHiddenFieldUsedInLogic = (survey: TSurvey, hiddenFieldId: strin
};
// Derive questions from blocks (cast as questions to access logic properties)
const questions = (survey.blocks?.flatMap((b) => b.elements) ?? []) as unknown as TSurveyQuestion[];
const questions = getQuestionsFromBlocks(survey.blocks);
return questions.findIndex((question) =>
(question.logic as unknown as TSurveyBlockLogic[])?.some(isUsedInLogicRule)
);
return questions.findIndex((question) => {
const { block } = findElementLocation(survey, question.id);
if (!block) {
return false;
}
return block.logic?.some(isUsedInLogicRule);
});
};
export const getSurveyFollowUpActionDefaultBody = (t: TFunction): string => {
@@ -1520,11 +1553,15 @@ export const findEndingCardUsedInLogic = (survey: TSurvey, endingCardId: string)
};
// Derive questions from blocks (cast as questions to access logic properties)
const questions = (survey.blocks?.flatMap((b) => b.elements) ?? []) as unknown as TSurveyQuestion[];
const questions = getQuestionsFromBlocks(survey.blocks);
return questions.findIndex(
(question) =>
question.logicFallback === endingCardId ||
(question.logic as unknown as TSurveyBlockLogic[])?.some(isUsedInLogicRule)
);
return questions.findIndex((question) => {
const { block } = findElementLocation(survey, question.id);
if (!block) {
return false;
}
return block.logicFallback === endingCardId || block.logic?.some(isUsedInLogicRule);
});
};

View File

@@ -22,6 +22,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { recallToHeadline } from "@/lib/utils/recall";
import { getQuestionsFromBlocks } from "@/modules/survey/editor/lib/blocks";
import { getSurveyFollowUpActionDefaultBody } from "@/modules/survey/editor/lib/utils";
import {
TCreateSurveyFollowUpForm,
@@ -102,7 +103,7 @@ export const FollowUpModal = ({
const emailSendToOptions: EmailSendToOption[] = useMemo(() => {
// Derive questions from blocks
const questions = localSurvey.blocks.flatMap((block) => block.elements);
const questions = getQuestionsFromBlocks(localSurvey.blocks);
const openTextAndContactQuestions = questions.filter((question) => {
if (question.type === TSurveyElementTypeEnum.ContactInfo) {