mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-20 11:22:55 -05:00
fix: fixes sonar issues and coderabbit feedback (#6902)
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { type TSurveyBlock, TSurveyBlockLogicAction } from "@formbricks/types/surveys/blocks";
|
||||
import {
|
||||
type TSurveyBlock,
|
||||
type TSurveyBlockLogic,
|
||||
type TSurveyBlockLogicAction,
|
||||
} from "@formbricks/types/surveys/blocks";
|
||||
import { type TConditionGroup, type TSingleCondition } from "@formbricks/types/surveys/logic";
|
||||
import {
|
||||
type TSurveyEnding,
|
||||
@@ -415,6 +419,26 @@ export const transformQuestionsToBlocks = (
|
||||
return blocks as TSurveyBlock[];
|
||||
};
|
||||
|
||||
const transformBlockLogicToQuestionLogic = (
|
||||
blockLogic: TSurveyBlockLogic[],
|
||||
blockIdToQuestionId: Map<string, string>,
|
||||
endingIds: Set<string>
|
||||
): unknown[] => {
|
||||
return blockLogic.map((item) => {
|
||||
const updatedConditions = convertElementToQuestionType(item.conditions);
|
||||
|
||||
if (!updatedConditions || !isConditionGroup(updatedConditions)) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
conditions: updatedConditions,
|
||||
actions: reverseLogicActions(item.actions, blockIdToQuestionId, endingIds),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const transformBlocksToQuestions = (
|
||||
blocks: TSurveyBlock[],
|
||||
endings: TSurveyEnding[] = []
|
||||
@@ -442,21 +466,7 @@ export const transformBlocksToQuestions = (
|
||||
}
|
||||
|
||||
if (Array.isArray(block.logic) && block.logic.length > 0) {
|
||||
element.logic = block.logic.map(
|
||||
(item: { id: string; conditions: TConditionGroup; actions: TSurveyBlockLogicAction[] }) => {
|
||||
const updatedConditions = convertElementToQuestionType(item.conditions);
|
||||
|
||||
if (!updatedConditions || !isConditionGroup(updatedConditions)) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
conditions: updatedConditions,
|
||||
actions: reverseLogicActions(item.actions, blockIdToQuestionId, endingIds),
|
||||
};
|
||||
}
|
||||
);
|
||||
element.logic = transformBlockLogicToQuestionLogic(block.logic, blockIdToQuestionId, endingIds);
|
||||
}
|
||||
|
||||
if (block.logicFallback) {
|
||||
|
||||
@@ -209,6 +209,12 @@ export const writeData = async (
|
||||
responses: string[],
|
||||
elements: string[]
|
||||
) => {
|
||||
if (responses.length !== elements.length) {
|
||||
throw new Error(
|
||||
`Array length mismatch: responses (${responses.length}) and elements (${elements.length}) must be equal`
|
||||
);
|
||||
}
|
||||
|
||||
// 1) Build the record payload
|
||||
const data: Record<string, string> = {};
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
|
||||
@@ -30,7 +30,7 @@ export const createI18nString = (
|
||||
return i18nString;
|
||||
} else {
|
||||
// It's a regular string, so create a new i18n object
|
||||
const i18nString: any = {
|
||||
const i18nString = {
|
||||
[targetLanguageCode ?? "default"]: text,
|
||||
};
|
||||
|
||||
@@ -46,7 +46,7 @@ export const createI18nString = (
|
||||
};
|
||||
|
||||
// Type guard to check if an object is an I18nString
|
||||
export const isI18nObject = (obj: any): obj is TI18nString => {
|
||||
export const isI18nObject = (obj: unknown): obj is TI18nString => {
|
||||
return typeof obj === "object" && obj !== null && Object.keys(obj).includes("default");
|
||||
};
|
||||
|
||||
@@ -92,7 +92,7 @@ export const iso639Identifiers = iso639Languages.map((language) => language.alph
|
||||
|
||||
// Helper function to add language keys to a multi-language object (e.g. survey or question)
|
||||
// Iterates over the object recursively and adds empty strings for new language keys
|
||||
export const addMultiLanguageLabels = (object: any, languageSymbols: string[]): any => {
|
||||
export const addMultiLanguageLabels = (object: unknown, languageSymbols: string[]): any => {
|
||||
// Helper function to add language keys to a multi-language object
|
||||
function addLanguageKeys(obj: { default: string; [key: string]: string }) {
|
||||
languageSymbols.forEach((lang) => {
|
||||
@@ -103,14 +103,14 @@ export const addMultiLanguageLabels = (object: any, languageSymbols: string[]):
|
||||
}
|
||||
|
||||
// Recursive function to process an object or array
|
||||
function processObject(obj: any) {
|
||||
function processObject(obj: unknown) {
|
||||
if (Array.isArray(obj)) {
|
||||
obj.forEach((item) => processObject(item));
|
||||
} else if (obj && typeof obj === "object") {
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
if (key === "default" && typeof obj[key] === "string") {
|
||||
addLanguageKeys(obj);
|
||||
addLanguageKeys(obj as { default: string; [key: string]: string });
|
||||
} else {
|
||||
processObject(obj[key]);
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ describe("Response Processing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getQuestionResponseMapping", () => {
|
||||
describe("getElementResponseMapping", () => {
|
||||
const mockSurvey = {
|
||||
id: "survey1",
|
||||
type: "link" as const,
|
||||
|
||||
@@ -351,7 +351,6 @@ describe("checkForInvalidMediaInBlocks", () => {
|
||||
|
||||
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"'
|
||||
);
|
||||
|
||||
@@ -151,6 +151,8 @@ export const RecallItemSelect = ({
|
||||
case "variable":
|
||||
const variable = localSurvey.variables.find((variable) => variable.id === recallItem.id);
|
||||
return variable?.type === "number" ? FileDigitIcon : FileTextIcon;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ export const ElementFormInput = ({
|
||||
: isEndingCard
|
||||
? localSurvey.endings[elementIdx - elements.length].id
|
||||
: currentElement.id;
|
||||
//eslint-disable-next-line
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isWelcomeCard, isEndingCard, currentElement?.id]);
|
||||
const endingCard = localSurvey.endings.find((ending) => ending.id === elementId);
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import toast from "react-hot-toast";
|
||||
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 { findVariableUsedInLogic, isUsedInQuota, isUsedInRecall } from "@/modules/survey/editor/lib/utils";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -133,26 +132,9 @@ export const SurveyVariablesCardItem = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// remove recall references from blocks
|
||||
setLocalSurvey((prevSurvey) => {
|
||||
const updatedBlocks = prevSurvey.blocks.map((block) => ({
|
||||
...block,
|
||||
elements: block.elements.map((element) => {
|
||||
const updatedHeadline = { ...element.headline };
|
||||
for (const [languageCode, headline] of Object.entries(element.headline)) {
|
||||
if (headline.includes(`recall:${variableToDelete.id}`)) {
|
||||
const recallInfo = extractRecallInfo(headline);
|
||||
if (recallInfo) {
|
||||
updatedHeadline[languageCode] = headline.replace(recallInfo, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...element, headline: updatedHeadline };
|
||||
}),
|
||||
}));
|
||||
|
||||
const updatedVariables = prevSurvey.variables.filter((v) => v.id !== variableToDelete.id);
|
||||
return { ...prevSurvey, variables: updatedVariables, blocks: updatedBlocks };
|
||||
return { ...prevSurvey, variables: updatedVariables };
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TSurveyCTAElement,
|
||||
TSurveyConsentElement,
|
||||
TSurveyElement,
|
||||
TSurveyElementTypeEnum,
|
||||
TSurveyMultipleChoiceElement,
|
||||
TSurveyRatingElement,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { FORBIDDEN_IDS } from "@formbricks/types/surveys/validation";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
@@ -36,72 +43,135 @@ export const getPrefillValue = (
|
||||
return Object.keys(prefillAnswer).length > 0 ? prefillAnswer : undefined;
|
||||
};
|
||||
|
||||
const validateOpenText = (): boolean => {
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateMultipleChoiceSingle = (
|
||||
question: TSurveyMultipleChoiceElement,
|
||||
answer: string,
|
||||
language: string
|
||||
): boolean => {
|
||||
if (question.type !== TSurveyElementTypeEnum.MultipleChoiceSingle) return false;
|
||||
const choices = question.choices;
|
||||
const hasOther = choices[choices.length - 1].id === "other";
|
||||
|
||||
if (!hasOther) {
|
||||
return choices.some((choice) => choice.label[language] === answer);
|
||||
}
|
||||
|
||||
const matchesAnyChoice = choices.some((choice) => choice.label[language] === answer);
|
||||
|
||||
if (matchesAnyChoice) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const trimmedAnswer = answer.trim();
|
||||
return trimmedAnswer !== "";
|
||||
};
|
||||
|
||||
const validateMultipleChoiceMulti = (question: TSurveyElement, answer: string, language: string): boolean => {
|
||||
if (question.type !== TSurveyElementTypeEnum.MultipleChoiceMulti) return false;
|
||||
const choices = (
|
||||
question as TSurveyElement & { choices: Array<{ id: string; label: Record<string, string> }> }
|
||||
).choices;
|
||||
const hasOther = choices[choices.length - 1].id === "other";
|
||||
const lastChoiceLabel = hasOther ? choices[choices.length - 1].label[language] : undefined;
|
||||
|
||||
const answerChoices = answer
|
||||
.split(",")
|
||||
.map((ans) => ans.trim())
|
||||
.filter((ans) => ans !== "");
|
||||
|
||||
if (answerChoices.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasOther) {
|
||||
return answerChoices.every((ans: string) => choices.some((choice) => choice.label[language] === ans));
|
||||
}
|
||||
|
||||
let freeTextOtherCount = 0;
|
||||
for (const ans of answerChoices) {
|
||||
const matchesChoice = choices.some((choice) => choice.label[language] === ans);
|
||||
|
||||
if (matchesChoice) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ans === lastChoiceLabel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
freeTextOtherCount++;
|
||||
if (freeTextOtherCount > 1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateNPS = (answer: string): boolean => {
|
||||
try {
|
||||
const cleanedAnswer = answer.replace(/&/g, ";");
|
||||
const answerNumber = Number(JSON.parse(cleanedAnswer));
|
||||
return !isNaN(answerNumber) && answerNumber >= 0 && answerNumber <= 10;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const validateCTA = (question: TSurveyCTAElement, answer: string): boolean => {
|
||||
if (question.required && answer === "dismissed") return false;
|
||||
return answer === "clicked" || answer === "dismissed";
|
||||
};
|
||||
|
||||
const validateConsent = (question: TSurveyConsentElement, answer: string): boolean => {
|
||||
if (question.required && answer === "dismissed") return false;
|
||||
return answer === "accepted" || answer === "dismissed";
|
||||
};
|
||||
|
||||
const validateRating = (question: TSurveyRatingElement, answer: string): boolean => {
|
||||
if (question.type !== TSurveyElementTypeEnum.Rating) return false;
|
||||
const ratingQuestion = question;
|
||||
try {
|
||||
const cleanedAnswer = answer.replace(/&/g, ";");
|
||||
const answerNumber = Number(JSON.parse(cleanedAnswer));
|
||||
return answerNumber >= 1 && answerNumber <= (ratingQuestion.range ?? 5);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const validatePictureSelection = (answer: string): boolean => {
|
||||
const answerChoices = answer.split(",");
|
||||
return answerChoices.every((ans: string) => !isNaN(Number(ans)));
|
||||
};
|
||||
|
||||
const checkValidity = (question: TSurveyElement, answer: string, language: string): boolean => {
|
||||
if (question.required && (!answer || answer === "")) return false;
|
||||
|
||||
const validators: Partial<
|
||||
Record<TSurveyElementTypeEnum, (q: TSurveyElement, a: string, l: string) => boolean>
|
||||
> = {
|
||||
[TSurveyElementTypeEnum.OpenText]: () => validateOpenText(),
|
||||
[TSurveyElementTypeEnum.MultipleChoiceSingle]: (q, a, l) =>
|
||||
validateMultipleChoiceSingle(q as TSurveyMultipleChoiceElement, a, l),
|
||||
[TSurveyElementTypeEnum.MultipleChoiceMulti]: (q, a, l) => validateMultipleChoiceMulti(q, a, l),
|
||||
[TSurveyElementTypeEnum.NPS]: (_, a) => validateNPS(a),
|
||||
[TSurveyElementTypeEnum.CTA]: (q, a) => validateCTA(q as TSurveyCTAElement, a),
|
||||
[TSurveyElementTypeEnum.Consent]: (q, a) => validateConsent(q as TSurveyConsentElement, a),
|
||||
[TSurveyElementTypeEnum.Rating]: (q, a) => validateRating(q as TSurveyRatingElement, a),
|
||||
[TSurveyElementTypeEnum.PictureSelection]: (_, a) => validatePictureSelection(a),
|
||||
};
|
||||
|
||||
const validator = validators[question.type];
|
||||
if (!validator) return false;
|
||||
|
||||
try {
|
||||
switch (question.type) {
|
||||
case TSurveyElementTypeEnum.OpenText: {
|
||||
return true;
|
||||
}
|
||||
case TSurveyElementTypeEnum.MultipleChoiceSingle: {
|
||||
const hasOther = question.choices[question.choices.length - 1].id === "other";
|
||||
if (!hasOther) {
|
||||
if (!question.choices.find((choice) => choice.label[language] === answer)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (question.choices[question.choices.length - 1].label[language] === answer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
case TSurveyElementTypeEnum.MultipleChoiceMulti: {
|
||||
const answerChoices = answer.split(",");
|
||||
const hasOther = question.choices[question.choices.length - 1].id === "other";
|
||||
if (!hasOther) {
|
||||
if (
|
||||
!answerChoices.every((ans: string) =>
|
||||
question.choices.find((choice) => choice.label[language] === ans)
|
||||
)
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case TSurveyElementTypeEnum.NPS: {
|
||||
const cleanedAnswer = answer.replace(/&/g, ";");
|
||||
const answerNumber = Number(JSON.parse(cleanedAnswer));
|
||||
|
||||
if (isNaN(answerNumber)) return false;
|
||||
if (answerNumber < 0 || answerNumber > 10) return false;
|
||||
return true;
|
||||
}
|
||||
case TSurveyElementTypeEnum.CTA: {
|
||||
if (question.required && answer === "dismissed") return false;
|
||||
if (answer !== "clicked" && answer !== "dismissed") return false;
|
||||
return true;
|
||||
}
|
||||
case TSurveyElementTypeEnum.Consent: {
|
||||
if (question.required && answer === "dismissed") return false;
|
||||
if (answer !== "accepted" && answer !== "dismissed") return false;
|
||||
return true;
|
||||
}
|
||||
case TSurveyElementTypeEnum.Rating: {
|
||||
const cleanedAnswer = answer.replace(/&/g, ";");
|
||||
const answerNumber = Number(JSON.parse(cleanedAnswer));
|
||||
if (answerNumber < 1 || answerNumber > question.range) return false;
|
||||
return true;
|
||||
}
|
||||
case TSurveyElementTypeEnum.PictureSelection: {
|
||||
const answerChoices = answer.split(",");
|
||||
return answerChoices.every((ans: string) => !isNaN(Number(ans)));
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
return validator(question, answer, language);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,7 +3,11 @@ import { type TJsFileUploadParams } from "@formbricks/types/js";
|
||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import { type TUploadFileConfig } from "@formbricks/types/storage";
|
||||
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TSurveyElement,
|
||||
TSurveyElementTypeEnum,
|
||||
TSurveyRankingElement,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import { BackButton } from "@/components/buttons/back-button";
|
||||
import { SubmitButton } from "@/components/buttons/submit-button";
|
||||
import { ElementConditional } from "@/components/general/element-conditional";
|
||||
@@ -108,82 +112,85 @@ export function BlockConditional({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only run once when block mounts
|
||||
}, []);
|
||||
|
||||
const handleBlockSubmit = (e?: Event) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
// Validate ranking element
|
||||
const validateRankingElement = (
|
||||
element: TSurveyRankingElement,
|
||||
response: unknown,
|
||||
form: HTMLFormElement
|
||||
): boolean => {
|
||||
const rankingElement = element;
|
||||
const hasIncompleteRanking =
|
||||
(rankingElement.required &&
|
||||
(!Array.isArray(response) || response.length !== rankingElement.choices.length)) ||
|
||||
(!rankingElement.required &&
|
||||
Array.isArray(response) &&
|
||||
response.length > 0 &&
|
||||
response.length < rankingElement.choices.length);
|
||||
|
||||
if (hasIncompleteRanking) {
|
||||
form.requestSubmit();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Check if response is empty
|
||||
const isEmptyResponse = (response: unknown): boolean => {
|
||||
return (
|
||||
response === undefined ||
|
||||
response === null ||
|
||||
response === "" ||
|
||||
(Array.isArray(response) && response.length === 0) ||
|
||||
(typeof response === "object" && !Array.isArray(response) && Object.keys(response).length === 0)
|
||||
);
|
||||
};
|
||||
|
||||
// Validate a single element's form
|
||||
const validateElementForm = (element: TSurveyElement, form: HTMLFormElement): boolean => {
|
||||
// Check HTML5 validity first
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate all forms and check for custom validation rules
|
||||
const response = value[element.id];
|
||||
|
||||
// Custom validation for ranking questions
|
||||
if (element.type === TSurveyElementTypeEnum.Ranking && !validateRankingElement(element, response, form)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For other element types, check if required fields are empty
|
||||
if (element.required && isEmptyResponse(response)) {
|
||||
form.requestSubmit();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Find the first invalid form
|
||||
const findFirstInvalidForm = (): HTMLFormElement | null => {
|
||||
let firstInvalidForm: HTMLFormElement | null = null;
|
||||
|
||||
for (const element of block.elements) {
|
||||
const form = elementFormRefs.current.get(element.id);
|
||||
if (form) {
|
||||
// Check HTML5 validity first
|
||||
if (!form.checkValidity()) {
|
||||
if (!firstInvalidForm) {
|
||||
firstInvalidForm = form;
|
||||
}
|
||||
form.reportValidity();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Custom validation for ranking questions
|
||||
if (element.type === TSurveyElementTypeEnum.Ranking) {
|
||||
const response = value[element.id];
|
||||
const rankingElement = element;
|
||||
|
||||
// Check if ranking is incomplete
|
||||
const hasIncompleteRanking =
|
||||
(rankingElement.required &&
|
||||
(!Array.isArray(response) || response.length !== rankingElement.choices.length)) ||
|
||||
(!rankingElement.required &&
|
||||
Array.isArray(response) &&
|
||||
response.length > 0 &&
|
||||
response.length < rankingElement.choices.length);
|
||||
|
||||
if (hasIncompleteRanking) {
|
||||
// Trigger the ranking form's submit to show the error message
|
||||
form.requestSubmit();
|
||||
if (!firstInvalidForm) {
|
||||
firstInvalidForm = form;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// For other element types, check if required fields are empty
|
||||
if (element.required) {
|
||||
const response = value[element.id];
|
||||
const isEmpty =
|
||||
response === undefined ||
|
||||
response === null ||
|
||||
response === "" ||
|
||||
(Array.isArray(response) && response.length === 0) ||
|
||||
(typeof response === "object" && !Array.isArray(response) && Object.keys(response).length === 0);
|
||||
|
||||
if (isEmpty) {
|
||||
form.requestSubmit();
|
||||
if (!firstInvalidForm) {
|
||||
firstInvalidForm = form;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (form && !validateElementForm(element, form)) {
|
||||
if (!firstInvalidForm) {
|
||||
firstInvalidForm = form;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If any form is invalid, scroll to it and stop
|
||||
if (firstInvalidForm) {
|
||||
firstInvalidForm.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
return;
|
||||
}
|
||||
return firstInvalidForm;
|
||||
};
|
||||
|
||||
// Collect TTC values from forms
|
||||
const collectTtcValues = (): TResponseTtc => {
|
||||
// Clear the TTC collector before collecting new values
|
||||
ttcCollectorRef.current = {};
|
||||
|
||||
// Call each form's submit method to trigger TTC calculation
|
||||
// The forms will call handleTtcCollect synchronously with their TTC values
|
||||
block.elements.forEach((element) => {
|
||||
const form = elementFormRefs.current.get(element.id);
|
||||
if (form) {
|
||||
@@ -192,10 +199,8 @@ export function BlockConditional({
|
||||
});
|
||||
|
||||
// Collect TTC from the ref (populated synchronously by form submissions)
|
||||
// Falls back to state for elements that may have TTC from user interactions
|
||||
const blockTtc: TResponseTtc = {};
|
||||
block.elements.forEach((element) => {
|
||||
// Prefer freshly calculated TTC from form submission, fall back to state
|
||||
if (ttcCollectorRef.current[element.id] !== undefined) {
|
||||
blockTtc[element.id] = ttcCollectorRef.current[element.id];
|
||||
} else if (ttc[element.id] !== undefined) {
|
||||
@@ -203,14 +208,37 @@ export function BlockConditional({
|
||||
}
|
||||
});
|
||||
|
||||
// Collect responses for all elements in this block
|
||||
return blockTtc;
|
||||
};
|
||||
|
||||
// Collect responses for all elements in this block
|
||||
const collectBlockResponses = (): TResponseData => {
|
||||
const blockResponses: TResponseData = {};
|
||||
block.elements.forEach((element) => {
|
||||
if (value[element.id] !== undefined) {
|
||||
blockResponses[element.id] = value[element.id];
|
||||
}
|
||||
});
|
||||
return blockResponses;
|
||||
};
|
||||
|
||||
const handleBlockSubmit = (e?: Event) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// Validate all forms and check for custom validation rules
|
||||
const firstInvalidForm = findFirstInvalidForm();
|
||||
|
||||
// If any form is invalid, scroll to it and stop
|
||||
if (firstInvalidForm) {
|
||||
firstInvalidForm.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect TTC and responses, then submit
|
||||
const blockTtc = collectTtcValues();
|
||||
const blockResponses = collectBlockResponses();
|
||||
onSubmit(blockResponses, blockTtc);
|
||||
};
|
||||
|
||||
|
||||
@@ -123,6 +123,7 @@ export function ElementConditional({
|
||||
return null;
|
||||
}
|
||||
|
||||
// NOSONAR - This is readable enough and can't be changed
|
||||
const renderElement = () => {
|
||||
switch (element.type) {
|
||||
case TSurveyElementTypeEnum.OpenText:
|
||||
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
TResponseVariables,
|
||||
} from "@formbricks/types/responses";
|
||||
import { TUploadFileConfig } from "@formbricks/types/storage";
|
||||
import { TSurveyBlock, TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { BlockConditional } from "@/components/general/block-conditional";
|
||||
import { EndingCard } from "@/components/general/ending-card";
|
||||
import { ErrorComponent } from "@/components/general/error-component";
|
||||
@@ -350,20 +352,21 @@ export function Survey({
|
||||
};
|
||||
|
||||
const makeQuestionsRequired = (requiredQuestionIds: string[]): void => {
|
||||
const updateElementIfRequired = (element: TSurveyElement) => {
|
||||
if (requiredQuestionIds.includes(element.id)) {
|
||||
return { ...element, required: true };
|
||||
}
|
||||
return element;
|
||||
};
|
||||
|
||||
const updateBlockElements = (block: TSurveyBlock) => ({
|
||||
...block,
|
||||
elements: block.elements.map(updateElementIfRequired),
|
||||
});
|
||||
|
||||
setlocalSurvey((prevSurvey) => ({
|
||||
...prevSurvey,
|
||||
blocks: prevSurvey.blocks.map((block) => ({
|
||||
...block,
|
||||
elements: block.elements.map((element) => {
|
||||
if (requiredQuestionIds.includes(element.id)) {
|
||||
return {
|
||||
...element,
|
||||
required: true,
|
||||
};
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
})),
|
||||
blocks: prevSurvey.blocks.map(updateBlockElements),
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -371,20 +374,24 @@ export function Survey({
|
||||
const questionsToRevert = questionRequiredByMap.current[questionId] || [];
|
||||
|
||||
if (questionsToRevert.length > 0) {
|
||||
const revertElementIfNeeded = (element: TSurveyElement) => {
|
||||
if (questionsToRevert.includes(element.id)) {
|
||||
return {
|
||||
...element,
|
||||
required: originalQuestionRequiredStates[element.id] ?? element.required,
|
||||
};
|
||||
}
|
||||
return element;
|
||||
};
|
||||
|
||||
const updateBlockElements = (block: TSurveyBlock) => ({
|
||||
...block,
|
||||
elements: block.elements.map(revertElementIfNeeded),
|
||||
});
|
||||
|
||||
setlocalSurvey((prevSurvey) => ({
|
||||
...prevSurvey,
|
||||
blocks: prevSurvey.blocks.map((block) => ({
|
||||
...block,
|
||||
elements: block.elements.map((element) => {
|
||||
if (questionsToRevert.includes(element.id)) {
|
||||
return {
|
||||
...element,
|
||||
required: originalQuestionRequiredStates[element.id] ?? element.required,
|
||||
};
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
})),
|
||||
blocks: prevSurvey.blocks.map(updateBlockElements),
|
||||
}));
|
||||
|
||||
// remove the question from the map
|
||||
@@ -428,54 +435,85 @@ export function Survey({
|
||||
throw new Error("Block not found");
|
||||
}
|
||||
|
||||
let firstJumpTarget: string | undefined;
|
||||
const allRequiredQuestionIds: string[] = [];
|
||||
|
||||
let calculationResults = { ...currentVariables };
|
||||
const localResponseData = { ...responseData, ...data };
|
||||
let calculationResults = { ...currentVariables };
|
||||
|
||||
// Process a single logic rule
|
||||
const processLogicRule = (
|
||||
logic: TSurveyBlockLogic,
|
||||
currentJumpTarget: string | undefined,
|
||||
currentRequiredIds: string[]
|
||||
): { jumpTarget: string | undefined; requiredIds: string[]; updatedCalculations: TResponseVariables } => {
|
||||
const isLogicMet = evaluateLogic(
|
||||
localSurvey,
|
||||
localResponseData,
|
||||
calculationResults,
|
||||
logic.conditions,
|
||||
selectedLanguage
|
||||
);
|
||||
|
||||
if (!isLogicMet) {
|
||||
return {
|
||||
jumpTarget: currentJumpTarget,
|
||||
requiredIds: currentRequiredIds,
|
||||
updatedCalculations: calculationResults,
|
||||
};
|
||||
}
|
||||
|
||||
const { jumpTarget, requiredQuestionIds, calculations } = performActions(
|
||||
localSurvey,
|
||||
logic.actions,
|
||||
localResponseData,
|
||||
calculationResults
|
||||
);
|
||||
|
||||
const newJumpTarget = jumpTarget && !currentJumpTarget ? jumpTarget : currentJumpTarget;
|
||||
const newRequiredIds = [...currentRequiredIds, ...requiredQuestionIds];
|
||||
const updatedCalculations = { ...calculationResults, ...calculations };
|
||||
|
||||
return {
|
||||
jumpTarget: newJumpTarget,
|
||||
requiredIds: newRequiredIds,
|
||||
updatedCalculations,
|
||||
};
|
||||
};
|
||||
|
||||
// Evaluate block-level logic
|
||||
if (currentBlock.logic && currentBlock.logic.length > 0) {
|
||||
for (const logic of currentBlock.logic) {
|
||||
if (
|
||||
evaluateLogic(
|
||||
localSurvey,
|
||||
localResponseData,
|
||||
calculationResults,
|
||||
logic.conditions,
|
||||
selectedLanguage
|
||||
)
|
||||
) {
|
||||
const { jumpTarget, requiredQuestionIds, calculations } = performActions(
|
||||
localSurvey,
|
||||
logic.actions,
|
||||
localResponseData,
|
||||
calculationResults
|
||||
);
|
||||
const evaluateBlockLogic = () => {
|
||||
let firstJumpTarget: string | undefined;
|
||||
const allRequiredQuestionIds: string[] = [];
|
||||
|
||||
if (jumpTarget && !firstJumpTarget) {
|
||||
firstJumpTarget = jumpTarget; // This is already a block ID from performActions
|
||||
}
|
||||
|
||||
allRequiredQuestionIds.push(...requiredQuestionIds);
|
||||
calculationResults = { ...calculationResults, ...calculations };
|
||||
if (currentBlock.logic && currentBlock.logic.length > 0) {
|
||||
for (const logic of currentBlock.logic) {
|
||||
const result = processLogicRule(logic, firstJumpTarget, allRequiredQuestionIds);
|
||||
firstJumpTarget = result.jumpTarget;
|
||||
allRequiredQuestionIds.length = 0;
|
||||
allRequiredQuestionIds.push(...result.requiredIds);
|
||||
calculationResults = result.updatedCalculations;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use logicFallback if no jump target was set (logicFallback is at block level)
|
||||
if (!firstJumpTarget && currentBlock.logicFallback) {
|
||||
firstJumpTarget = currentBlock.logicFallback;
|
||||
}
|
||||
|
||||
if (allRequiredQuestionIds.length > 0) {
|
||||
// Track which questions are being made required by this block
|
||||
if (currentBlock.elements[0]) {
|
||||
questionRequiredByMap.current[currentBlock.elements[0].id] = allRequiredQuestionIds;
|
||||
// Use logicFallback if no jump target was set
|
||||
if (!firstJumpTarget && currentBlock.logicFallback) {
|
||||
firstJumpTarget = currentBlock.logicFallback;
|
||||
}
|
||||
|
||||
makeQuestionsRequired(allRequiredQuestionIds);
|
||||
}
|
||||
return { firstJumpTarget, allRequiredQuestionIds };
|
||||
};
|
||||
|
||||
const { firstJumpTarget, allRequiredQuestionIds } = evaluateBlockLogic();
|
||||
|
||||
// Handle required questions
|
||||
const handleRequiredQuestions = (requiredIds: string[]) => {
|
||||
if (requiredIds.length > 0) {
|
||||
if (currentBlock.elements[0]) {
|
||||
questionRequiredByMap.current[currentBlock.elements[0].id] = requiredIds;
|
||||
}
|
||||
makeQuestionsRequired(requiredIds);
|
||||
}
|
||||
};
|
||||
|
||||
handleRequiredQuestions(allRequiredQuestionIds);
|
||||
|
||||
// Return the jump target (which is a block ID) or the next block in sequence
|
||||
const nextBlockId = firstJumpTarget || localSurvey.blocks[currentBlockIndex + 1]?.id;
|
||||
|
||||
@@ -313,7 +313,7 @@ export const validateId = (
|
||||
const combinedIds = [...existingElementIds, ...existingHiddenFieldIds, ...existingEndingCardIds];
|
||||
|
||||
if (combinedIds.findIndex((id) => id.toLowerCase() === field.toLowerCase()) !== -1) {
|
||||
return `${type} ID already exists in questions, hidden fields, or elements.`;
|
||||
return `${type} ID already exists in questions or hidden fields`;
|
||||
}
|
||||
|
||||
if (FORBIDDEN_IDS.includes(field)) {
|
||||
|
||||
Reference in New Issue
Block a user