mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-09 19:15:06 -05:00
Merge branch 'epic/survey-mqp' into feat/survey-summary-blocks
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user