Compare commits

...

3 Commits

Author SHA1 Message Date
Cursor Agent
e21328770d fix: add defensive checks for survey.endings in API and migration
- Add null check in app sync utils for recall info replacement
- Add null check in database migration for endings cleanup
2026-02-05 08:52:28 +00:00
Cursor Agent
2a4aa806db test: add tests for survey.endings null/undefined handling
- Verify endings defaults to empty array when null
- Verify endings defaults to empty array when undefined
- Verify endings is preserved when provided
2026-02-05 08:51:03 +00:00
Cursor Agent
20d4a14e06 fix: prevent TypeError when accessing survey.endings.length
- Add default empty array for survey.endings in transformPrismaSurvey
- Add optional chaining to all survey.endings accesses in runtime code
- Ensure defensive null checks throughout survey rendering components

Fixes FORMBRICKS-GR
2026-02-05 08:47:06 +00:00
15 changed files with 54 additions and 16 deletions

View File

@@ -38,7 +38,7 @@ export const replaceAttributeRecall = (survey: TSurvey, attributes: TAttributes)
}
});
}
surveyTemp.endings.forEach((ending) => {
(surveyTemp.endings ?? []).forEach((ending) => {
if (ending.type === "endScreen") {
languages.forEach((language) => {
if (ending.headline && ending.headline[language]?.includes("recall:")) {

View File

@@ -110,7 +110,7 @@ export const QuotaModal = ({
],
},
action: quota?.action || "endSurvey",
endingCardId: quota?.endingCardId || survey.endings[0]?.id || null,
endingCardId: quota?.endingCardId || survey.endings?.[0]?.id || null,
countPartialSubmissions: quota?.countPartialSubmissions || false,
surveyId: survey.id,
};

View File

@@ -115,7 +115,7 @@ export const ElementFormInput = ({
: currentElement.id;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isWelcomeCard, isEndingCard, currentElement?.id]);
const endingCard = localSurvey.endings.find((ending) => ending.id === elementId);
const endingCard = (localSurvey.endings ?? []).find((ending) => ending.id === elementId);
const surveyLanguageCodes = useMemo(
() => extractLanguageCodes(localSurvey.languages),

View File

@@ -57,7 +57,7 @@ export const getEndingCardText = (
questionIdx: number
): TI18nString => {
const endingCardIndex = questionIdx - questions.length;
const card = survey.endings[endingCardIndex];
const card = survey.endings?.[endingCardIndex];
if (card?.type === "endScreen") {
return (card[id as keyof typeof card] as TI18nString) || createI18nString("", surveyLanguageCodes);

View File

@@ -85,7 +85,7 @@ export const EditorCardMenu = ({
const elements = getElementsFromBlocks(survey.blocks);
const isDeleteDisabled =
cardType === "element" ? elements.length === 1 : survey.type === "link" && survey.endings.length === 1;
cardType === "element" ? elements.length === 1 : survey.type === "link" && (survey.endings?.length ?? 0) === 1;
const availableElementTypes = isCxMode ? getCXElementNameMap(t) : getElementNameMap(t);

View File

@@ -102,6 +102,42 @@ describe("Survey Utils", () => {
expect(result.segment).toBeNull();
expect(result.id).toBe("surveyJs");
});
test("should default endings to empty array when null", () => {
const surveyPrisma = {
id: "survey5",
name: "Survey with null endings",
displayPercentage: "100",
segment: null,
endings: null,
};
const result = transformPrismaSurvey<TSurvey>(surveyPrisma);
expect(result.endings).toEqual([]);
});
test("should default endings to empty array when undefined", () => {
const surveyPrisma = {
id: "survey6",
name: "Survey with undefined endings",
displayPercentage: "100",
segment: null,
endings: undefined,
};
const result = transformPrismaSurvey<TSurvey>(surveyPrisma);
expect(result.endings).toEqual([]);
});
test("should preserve endings when provided", () => {
const surveyPrisma = {
id: "survey7",
name: "Survey with endings",
displayPercentage: "100",
segment: null,
endings: [{ id: "ending1", type: "endScreen" }],
};
const result = transformPrismaSurvey<TSurvey>(surveyPrisma);
expect(result.endings).toEqual([{ id: "ending1", type: "endScreen" }]);
});
});
describe("buildWhereClause", () => {

View File

@@ -21,6 +21,8 @@ export const transformPrismaSurvey = <T extends TSurvey | TJsEnvironmentStateSur
displayPercentage: Number(surveyPrisma.displayPercentage) || null,
segment,
customHeadScriptsMode: surveyPrisma.customHeadScriptsMode,
// Ensure endings is always an array to prevent runtime errors
endings: surveyPrisma.endings ?? [],
} as T;
return transformedSurvey;

View File

@@ -102,7 +102,7 @@ export const PreviewSurvey = ({
}
// check the endings
const ending = survey.endings.find((e) => e.id === newElementId);
const ending = (survey.endings ?? []).find((e) => e.id === newElementId);
if (ending) {
setBlockId(ending.id);
return;
@@ -119,7 +119,7 @@ export const PreviewSurvey = ({
const onFinished = () => {
// close modal if there are no elements left
if (survey.type === "app" && survey.endings.length === 0) {
if (survey.type === "app" && (survey.endings?.length ?? 0) === 0) {
setIsModalOpen(false);
setTimeout(() => {
if (survey.blocks[0]) {

View File

@@ -62,7 +62,7 @@ export const removeEmptyImageAndVideoUrlsFromElements: MigrationScript = {
delete cleanedWelcomeCard.videoUrl;
}
const cleanedEndings = survey.endings.map((ending) => {
const cleanedEndings = (survey.endings ?? []).map((ending) => {
const cleanedEnding = { ...ending };
if (cleanedEnding.imageUrl === "") {
delete cleanedEnding.imageUrl;

View File

@@ -13,7 +13,7 @@ export function ProgressBar({ survey, blockId }: ProgressBarProps) {
[survey.blocks, blockId]
);
const endingCardIds = useMemo(() => survey.endings.map((ending) => ending.id), [survey.endings]);
const endingCardIds = useMemo(() => (survey.endings ?? []).map((ending) => ending.id), [survey.endings]);
const calculateProgress = useCallback(
(blockIndex: number) => {

View File

@@ -77,7 +77,7 @@ export function RenderSurvey(props: SurveyContainerProps) {
close();
}
},
props.survey.endings.length ? 3000 : 0 // close modal automatically after 3 seconds if no ending is enabled; otherwise, close immediately
(props.survey.endings?.length ?? 0) ? 3000 : 0 // close modal automatically after 3 seconds if no ending is enabled; otherwise, close immediately
);
}
}}

View File

@@ -422,7 +422,7 @@ export function Survey({
const evaluateLogicAndGetNextBlockId = (
data: TResponseData
): { nextBlockId: string | undefined; calculatedVariables: TResponseVariables } => {
const firstEndingId = survey.endings.length > 0 ? survey.endings[0].id : undefined;
const firstEndingId = (survey.endings?.length ?? 0) > 0 ? survey.endings[0].id : undefined;
if (blockId === "start")
return { nextBlockId: localSurvey.blocks[0]?.id || firstEndingId, calculatedVariables: {} };
@@ -657,7 +657,7 @@ export function Survey({
setIsSurveyFinished(finished);
const endingId = nextBlockId
? localSurvey.endings.find((ending) => ending.id === nextBlockId)?.id
? (localSurvey.endings ?? []).find((ending) => ending.id === nextBlockId)?.id
: undefined;
onChange(surveyResponseData);
@@ -776,7 +776,7 @@ export function Survey({
/>
);
} else if (blockIdx >= localSurvey.blocks.length) {
const endingCard = localSurvey.endings.find((ending) => {
const endingCard = (localSurvey.endings ?? []).find((ending) => {
return ending.id === blockId;
});
if (endingCard) {

View File

@@ -86,7 +86,7 @@ export function WelcomeCard({
const calculateTimeToComplete = () => {
const questions = getElementsFromSurveyBlocks(survey.blocks);
let totalCards = questions.length;
if (survey.endings.length > 0) totalCards += 1;
if ((survey.endings?.length ?? 0) > 0) totalCards += 1;
let idx = calculateElementIdx(survey, 0, totalCards);
if (idx === 0.5) {
idx = 1;

View File

@@ -164,7 +164,7 @@ export function StackedCardsContainer({
) : (
blockIdxTemp !== undefined &&
[prevBlockIdx, currentBlockIdx, nextBlockIdx, nextBlockIdx + 1].map((dynamicBlockIndex, index) => {
const hasEndingCard = survey.endings.length > 0;
const hasEndingCard = (survey.endings?.length ?? 0) > 0;
// Check for hiding extra card
if (dynamicBlockIndex > survey.blocks.length + (hasEndingCard ? 0 : -1)) return;
const offset = index - 1;

View File

@@ -87,7 +87,7 @@ export const calculateElementIdx = (
const currentQuestion = questions[currentQustionIdx];
const middleIdx = Math.floor(totalCards / 2);
const possibleNextBlockIds = getPossibleNextBlocks(survey.blocks, currentQuestion);
const endingCardIds = survey.endings.map((ending) => ending.id);
const endingCardIds = (survey.endings ?? []).map((ending) => ending.id);
// Convert block IDs to element IDs (get first element of each block)
const possibleNextQuestionIds = possibleNextBlockIds