Merge branch 'epic/survey-mqp' into feat/survey-summary-blocks

This commit is contained in:
pandeymangg
2025-11-10 16:18:43 +05:30
8 changed files with 54 additions and 92 deletions

View File

@@ -30,6 +30,7 @@ interface LocalizedEditorProps {
isCard?: boolean; // Flag to indicate if this is a welcome/ending card
autoFocus?: boolean;
isExternalUrlsAllowed?: boolean;
suppressUpdates?: () => boolean; // Function to check if updates should be suppressed (e.g., during deletion)
}
const checkIfValueIsIncomplete = (
@@ -62,6 +63,7 @@ export function LocalizedEditor({
isCard,
autoFocus,
isExternalUrlsAllowed,
suppressUpdates,
}: Readonly<LocalizedEditorProps>) {
// Derive questions from blocks for migrated surveys
const questions = useMemo(() => getQuestionsFromBlocks(localSurvey.blocks), [localSurvey.blocks]);
@@ -96,6 +98,12 @@ export function LocalizedEditor({
key={`${questionId}-${id}-${selectedLanguageCode}`}
setFirstRender={setFirstRender}
setText={(v: string) => {
// Early exit if updates are suppressed (e.g., during deletion)
// This prevents race conditions where setText fires with stale props before React updates state
if (suppressUpdates?.()) {
return;
}
let sanitizedContent = v;
if (!isExternalUrlsAllowed) {
sanitizedContent = v.replaceAll(/<a[^>]*>(.*?)<\/a>/gi, "$1");
@@ -131,7 +139,8 @@ export function LocalizedEditor({
return;
}
if (currentQuestion && currentQuestion[id] !== undefined) {
// Check if the field exists on the question (not just if it's not undefined)
if (currentQuestion && id in currentQuestion && currentQuestion[id] !== undefined) {
const translatedContent = {
...value,
[selectedLanguageCode]: sanitizedContent,

View File

@@ -290,6 +290,7 @@ export const QuestionFormInput = ({
const [animationParent] = useAutoAnimate();
const [internalFirstRender, setInternalFirstRender] = useState(true);
const suppressEditorUpdatesRef = useRef(false);
// Use external firstRender state if provided, otherwise use internal state
const firstRender = externalFirstRender ?? internalFirstRender;
@@ -335,8 +336,8 @@ export const QuestionFormInput = ({
if (url) {
const update =
fileType === "video"
? { videoUrl: url[0], imageUrl: "" }
: { imageUrl: url[0], videoUrl: "" };
? { videoUrl: url[0], imageUrl: undefined }
: { imageUrl: url[0], videoUrl: undefined };
if ((isWelcomeCard || isEndingCard) && updateSurvey) {
updateSurvey(update);
} else if (updateQuestion) {
@@ -371,6 +372,7 @@ export const QuestionFormInput = ({
isCard={isWelcomeCard || isEndingCard}
autoFocus={autoFocus}
isExternalUrlsAllowed={isExternalUrlsAllowed}
suppressUpdates={() => suppressEditorUpdatesRef.current}
/>
</div>
@@ -399,6 +401,12 @@ export const QuestionFormInput = ({
onClick={(e) => {
e.preventDefault();
// Suppress Editor updates BEFORE calling updateQuestion to prevent race condition
// Use ref for immediate synchronous access
if (id === "subheader") {
suppressEditorUpdatesRef.current = true;
}
if (updateSurvey) {
updateSurvey({ subheader: undefined });
}
@@ -406,6 +414,13 @@ export const QuestionFormInput = ({
if (updateQuestion) {
updateQuestion(questionIdx, { subheader: undefined });
}
// Re-enable updates after a short delay to allow state to update
if (id === "subheader") {
setTimeout(() => {
suppressEditorUpdatesRef.current = false;
}, 100);
}
}}>
<TrashIcon />
</Button>
@@ -449,7 +464,7 @@ export const QuestionFormInput = ({
onAddFallback={() => {
inputRef.current?.focus();
}}
isRecallAllowed={id === "headline" || id === "subheader"}
isRecallAllowed={false}
usedLanguageCode={usedLanguageCode}
render={({
value,
@@ -460,32 +475,6 @@ export const QuestionFormInput = ({
}) => {
return (
<div className="flex flex-col gap-4 bg-white" ref={animationParent}>
{showImageUploader && id === "headline" && (
<FileInput
id="question-image"
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
environmentId={localSurvey.environmentId}
onFileUpload={(url: string[] | undefined, fileType: "image" | "video") => {
if (url) {
const update =
fileType === "video"
? { videoUrl: url[0], imageUrl: "" }
: { imageUrl: url[0], videoUrl: "" };
if (isEndingCard && updateSurvey) {
updateSurvey(update);
} else if (updateQuestion) {
updateQuestion(questionIdx, update);
}
}
}}
fileUrl={getFileUrl()}
videoUrl={getVideoUrl()}
isVideoAllowed={true}
maxSizeInMB={5}
isStorageConfigured={isStorageConfigured}
/>
)}
<div className="flex w-full items-center space-x-2">
<div className="group relative w-full">
{languageIndicator}
@@ -532,52 +521,11 @@ export const QuestionFormInput = ({
isTranslationIncomplete
}
autoComplete={isRecallSelectVisible ? "off" : "on"}
autoFocus={id === "headline"}
autoFocus={false}
onKeyDown={handleKeyDown}
/>
{recallComponents}
</div>
<>
{id === "headline" && !isWelcomeCard && (
<TooltipRenderer tooltipContent={t("environments.surveys.edit.add_photo_or_video")}>
<Button
variant="secondary"
size="icon"
aria-label="Toggle image uploader"
data-testid="toggle-image-uploader-button"
className="ml-2"
onClick={(e) => {
e.preventDefault();
setShowImageUploader((prev) => !prev);
}}>
<ImagePlusIcon />
</Button>
</TooltipRenderer>
)}
{renderRemoveDescriptionButton() ? (
<TooltipRenderer tooltipContent={t("environments.surveys.edit.remove_description")}>
<Button
variant="secondary"
size="icon"
aria-label="Remove description"
className="ml-2"
onClick={(e) => {
e.preventDefault();
if (updateSurvey) {
updateSurvey({ subheader: undefined });
}
if (updateQuestion) {
updateQuestion(questionIdx, { subheader: undefined });
}
}}>
<TrashIcon />
</Button>
</TooltipRenderer>
) : null}
</>
</div>
</div>
);

View File

@@ -200,21 +200,23 @@ export const FileInput = ({
setSelectedFiles(getSelectedFiles());
}, [fileUrl]);
// useEffect to handle the state when switching between 'image' and 'video' tabs.
useEffect(() => {
if (activeTab === "image" && typeof imageUrlTemp === "string") {
// Temporarily store the current video URL before switching tabs.
setVideoUrlTemp(videoUrl ?? "");
// Re-upload the image using the temporary image URL.
onFileUpload([imageUrlTemp], "image");
if (imageUrlTemp) {
onFileUpload([imageUrlTemp], "image");
}
} else if (activeTab === "video") {
// Temporarily store the current image URL before switching tabs.
setImageUrlTemp(fileUrl ?? "");
// Re-upload the video using the temporary video URL.
onFileUpload([videoUrlTemp], "video");
if (videoUrlTemp) {
onFileUpload([videoUrlTemp], "video");
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only run when activeTab changes to avoid infinite loops
}, [activeTab]);
return (

View File

@@ -19,7 +19,7 @@ export function ProgressBar({ survey, questionId }: ProgressBarProps) {
const calculateProgress = useCallback(
(index: number) => {
let totalCards = questions.length;
let totalCards = survey.blocks.length;
if (endingCardIds.length > 0) totalCards += 1;
let idx = index;
@@ -28,7 +28,7 @@ export function ProgressBar({ survey, questionId }: ProgressBarProps) {
const elementIdx = calculateElementIdx(survey, idx, totalCards);
return elementIdx / totalCards;
},
[survey, questions.length, endingCardIds.length]
[survey, endingCardIds.length]
);
const progressArray = useMemo(() => {

View File

@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import { type TResponseData } from "@formbricks/types/responses";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { type TSurveyElement } from "@formbricks/types/surveys/elements";
import { SubmitButton } from "@/components/buttons/submit-button";
import { processResponseData } from "@/lib/response";

View File

@@ -27,8 +27,13 @@ import { evaluateLogic, performActions } from "@/lib/logic";
import { parseRecallInformation } from "@/lib/recall";
import { ResponseQueue } from "@/lib/response-queue";
import { SurveyState } from "@/lib/survey-state";
import { findBlockByElementId, getFirstElementIdInBlock, getQuestionsFromSurvey } from "@/lib/utils";
import { cn, getDefaultLanguageCode } from "@/lib/utils";
import {
cn,
findBlockByElementId,
getDefaultLanguageCode,
getFirstElementIdInBlock,
getQuestionsFromSurvey,
} from "@/lib/utils";
import { TResponseErrorCodesEnum } from "@/types/response-error-codes";
interface VariableStackEntry {

View File

@@ -68,12 +68,12 @@ export const replaceRecallInfo = (
return modifiedText;
};
export const parseRecallInformation = <T extends TSurveyElement>(
question: T,
export const parseRecallInformation = (
question: TSurveyElement,
languageCode: string,
responseData: TResponseData,
variables: TResponseVariables
): T => {
): TSurveyElement => {
const modifiedQuestion = JSON.parse(JSON.stringify(question));
if (question.headline[languageCode].includes("recall:")) {
modifiedQuestion.headline[languageCode] = replaceRecallInfo(

View File

@@ -215,9 +215,8 @@ export const checkIfSurveyIsRTL = (survey: TJsEnvironmentStateSurvey, languageCo
* @param survey The survey object with blocks
* @returns An array of TSurveyElement (pure elements without block-level properties)
*/
export const getQuestionsFromSurvey = (survey: TJsEnvironmentStateSurvey): TSurveyElement[] => {
return survey.blocks?.flatMap((block) => block.elements) ?? [];
};
export const getQuestionsFromSurvey = (survey: TJsEnvironmentStateSurvey): TSurveyElement[] =>
survey.blocks.flatMap((block) => block.elements);
/**
* Finds the parent block that contains the specified element ID.
@@ -226,9 +225,8 @@ export const getQuestionsFromSurvey = (survey: TJsEnvironmentStateSurvey): TSurv
* @param elementId The ID of the element to find
* @returns The parent block or undefined if not found
*/
export const findBlockByElementId = (survey: TJsEnvironmentStateSurvey, elementId: string) => {
return survey.blocks?.find((b) => b.elements.some((e) => e.id === elementId));
};
export const findBlockByElementId = (survey: TJsEnvironmentStateSurvey, elementId: string) =>
survey.blocks.find((b) => b.elements.some((e) => e.id === elementId));
/**
* Converts a block ID to the first element ID in that block.
@@ -241,6 +239,6 @@ export const getFirstElementIdInBlock = (
survey: TJsEnvironmentStateSurvey,
blockId: string
): string | undefined => {
const block = survey.blocks?.find((b) => b.id === blockId);
const block = survey.blocks.find((b) => b.id === blockId);
return block?.elements[0]?.id;
};