diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/LogicEditor.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/LogicEditor.tsx index 386d43fb88..c5b8b20f28 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/LogicEditor.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/LogicEditor.tsx @@ -20,6 +20,7 @@ import { import { QuestionMarkCircleIcon, TrashIcon } from "@heroicons/react/24/solid"; import { ChevronDown, SplitIcon } from "lucide-react"; import { useMemo } from "react"; +import { toast } from "react-hot-toast"; import { BsArrowDown, BsArrowReturnRight } from "react-icons/bs"; interface LogicEditorProps { @@ -141,6 +142,19 @@ export default function LogicEditor({ }; const addLogic = () => { + if (question.logic && question.logic?.length >= 0) { + const hasUndefinedLogic = question.logic.some( + (logic) => + logic.condition === undefined && logic.value === undefined && logic.destination === undefined + ); + if (hasUndefinedLogic) { + toast("Please fill current logic jumps first.", { + icon: "🤓", + }); + return; + } + } + const newLogic: TSurveyLogic[] = !question.logic ? [] : question.logic; newLogic.push({ condition: undefined, diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/MultipleChoiceMultiForm.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/MultipleChoiceMultiForm.tsx index e8efe2bbd0..30e121b28e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/MultipleChoiceMultiForm.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/MultipleChoiceMultiForm.tsx @@ -33,6 +33,7 @@ export default function MultipleChoiceMultiForm({ const [isNew, setIsNew] = useState(true); const [showSubheader, setShowSubheader] = useState(!!question.subheader); const questionRef = useRef(null); + const [isInvalidValue, setIsInvalidValue] = useState(null); const shuffleOptionsTypes = { none: { @@ -76,6 +77,24 @@ export default function MultipleChoiceMultiForm({ updateQuestion(questionIdx, { choices: newChoices, logic: newLogic }); }; + const findDuplicateLabel = () => { + for (let i = 0; i < question.choices.length; i++) { + for (let j = i + 1; j < question.choices.length; j++) { + if (question.choices[i].label.trim() === question.choices[j].label.trim()) { + return question.choices[i].label.trim(); // Return the duplicate label + } + } + } + return null; + }; + + const findEmptyLabel = () => { + for (let i = 0; i < question.choices.length; i++) { + if (question.choices[i].label.trim() === "") return true; + } + return false; + }; + const addChoice = (choiceIdx?: number) => { setIsNew(false); // This question is no longer new. let newChoices = !question.choices ? [] : question.choices; @@ -112,6 +131,9 @@ export default function MultipleChoiceMultiForm({ const newChoices = !question.choices ? [] : question.choices.filter((_, idx) => idx !== choiceIdx); const choiceValue = question.choices[choiceIdx].label; + if (isInvalidValue === choiceValue) { + setIsInvalidValue(null); + } let newLogic: any[] = []; question.logic?.forEach((logic) => { let newL: string | string[] | undefined = logic.value; @@ -198,7 +220,20 @@ export default function MultipleChoiceMultiForm({ className={cn(choice.id === "other" && "border-dashed")} placeholder={choice.id === "other" ? "Other" : `Option ${choiceIdx + 1}`} onChange={(e) => updateChoice(choiceIdx, { label: e.target.value })} - isInvalid={isInValid && choice.label.trim() === ""} + onBlur={() => { + const duplicateLabel = findDuplicateLabel(); + if (duplicateLabel) { + setIsInvalidValue(duplicateLabel); + } else if (findEmptyLabel()) { + setIsInvalidValue(""); + } else { + setIsInvalidValue(null); + } + }} + isInvalid={ + (isInvalidValue === "" && choice.label.trim() === "") || + (isInvalidValue !== null && choice.label.trim() === isInvalidValue.trim()) + } /> {question.choices && question.choices.length > 2 && ( (null); const [isNew, setIsNew] = useState(true); const [showSubheader, setShowSubheader] = useState(!!question.subheader); + const [isInvalidValue, setIsInvalidValue] = useState(null); const questionRef = useRef(null); const shuffleOptionsTypes = { @@ -52,6 +53,24 @@ export default function MultipleChoiceSingleForm({ }, }; + const findDuplicateLabel = () => { + for (let i = 0; i < question.choices.length; i++) { + for (let j = i + 1; j < question.choices.length; j++) { + if (question.choices[i].label.trim() === question.choices[j].label.trim()) { + return question.choices[i].label.trim(); // Return the duplicate label + } + } + } + return null; + }; + + const findEmptyLabel = () => { + for (let i = 0; i < question.choices.length; i++) { + if (question.choices[i].label.trim() === "") return true; + } + return false; + }; + const updateChoice = (choiceIdx: number, updatedAttributes: { label: string }) => { const newLabel = updatedAttributes.label; const oldLabel = question.choices[choiceIdx].label; @@ -112,6 +131,9 @@ export default function MultipleChoiceSingleForm({ const newChoices = !question.choices ? [] : question.choices.filter((_, idx) => idx !== choiceIdx); const choiceValue = question.choices[choiceIdx].label; + if (isInvalidValue === choiceValue) { + setIsInvalidValue(null); + } let newLogic: any[] = []; question.logic?.forEach((logic) => { let newL: string | string[] | undefined = logic.value; @@ -197,8 +219,21 @@ export default function MultipleChoiceSingleForm({ value={choice.label} className={cn(choice.id === "other" && "border-dashed")} placeholder={choice.id === "other" ? "Other" : `Option ${choiceIdx + 1}`} + onBlur={() => { + const duplicateLabel = findDuplicateLabel(); + if (duplicateLabel) { + setIsInvalidValue(duplicateLabel); + } else if (findEmptyLabel()) { + setIsInvalidValue(""); + } else { + setIsInvalidValue(null); + } + }} onChange={(e) => updateChoice(choiceIdx, { label: e.target.value })} - isInvalid={isInValid && choice.label.trim() === ""} + isInvalid={ + (isInvalidValue === "" && choice.label.trim() === "") || + (isInvalidValue !== null && choice.label.trim() === isInvalidValue.trim()) + } /> {question.choices && question.choices.length > 2 && ( void; + className?: string; +}) { return (
@@ -53,6 +61,7 @@ export function BackButtonInput({ value, onChange }) { value={value} placeholder="Back" onChange={onChange} + className={className} />
@@ -235,9 +244,24 @@ export default function QuestionCard({ updateQuestion(questionIdx, { buttonLabel: e.target.value })} + onChange={(e) => { + const trimmedValue = e.target.value.trim(); // Remove spaces from the start and end + const hasInternalSpaces = /\S\s\S/.test(trimmedValue); // Test if there are spaces between words + + if ( + !trimmedValue.includes(" ") && + (trimmedValue === "" || hasInternalSpaces || !/\s/.test(trimmedValue)) + ) { + updateQuestion(questionIdx, { backButtonLabel: trimmedValue }); + } + }} /> @@ -245,6 +269,11 @@ export default function QuestionCard({ updateQuestion(questionIdx, { backButtonLabel: e.target.value })} + className={cn( + isInValid && + question.backButtonLabel?.trim() === "" && + "border border-red-600 focus:border-red-600" + )} /> )} @@ -255,6 +284,11 @@ export default function QuestionCard({ updateQuestion(questionIdx, { backButtonLabel: e.target.value })} + className={cn( + isInValid && + question.backButtonLabel?.trim() === "" && + "border border-red-600 focus:border-red-600" + )} /> )} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/SurveyMenuBar.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/SurveyMenuBar.tsx index b0f9bd0378..3291268377 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/SurveyMenuBar.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/SurveyMenuBar.tsx @@ -3,6 +3,7 @@ import AlertDialog from "@/components/shared/AlertDialog"; import DeleteDialog from "@/components/shared/DeleteDialog"; import SurveyStatusDropdown from "@/components/shared/SurveyStatusDropdown"; +import { QuestionType } from "@formbricks/types/questions"; import type { Survey } from "@formbricks/types/surveys"; import { TEnvironment } from "@formbricks/types/v1/environment"; import { TProduct } from "@formbricks/types/v1/product"; @@ -97,6 +98,14 @@ export default function SurveyMenuBar({ }; const validateSurvey = (survey) => { + const existingLogicConditions = new Set(); + const existingQuestionIds = new Set(); + + if (survey.questions.length === 0) { + toast.error("Please add at least one question"); + return; + } + faultyQuestions = []; for (let index = 0; index < survey.questions.length; index++) { const question = survey.questions[index]; @@ -109,7 +118,67 @@ export default function SurveyMenuBar({ // if there are any faulty questions, the user won't be allowed to save the survey if (faultyQuestions.length > 0) { setInvalidQuestions(faultyQuestions); - toast.error("Please fill required fields"); + toast.error("Please fill all required fields."); + return false; + } + + for (const question of survey.questions) { + if (existingQuestionIds.has(question.id)) { + toast.error("There are 2 identical question IDs. Please update one."); + return false; + } + existingQuestionIds.add(question.id); + + if ( + question.type === QuestionType.MultipleChoiceSingle || + question.type === QuestionType.MultipleChoiceMulti + ) { + const haveSameChoices = + question.choices.some((element) => element.label.trim() === "") || + question.choices.some((element, index) => + question.choices + .slice(index + 1) + .some((nextElement) => nextElement.label.trim() === element.label.trim()) + ); + + if (haveSameChoices) { + toast.error("You have two identical choices."); + return false; + } + } + + for (const logic of question.logic || []) { + const validFields = ["condition", "destination", "value"].filter( + (field) => logic[field] !== undefined + ).length; + + if (validFields < 2) { + setInvalidQuestions([question.id]); + toast.error("Incomplete logic jumps detected: Please fill or delete them."); + return false; + } + + if (question.required && logic.condition === "skipped") { + toast.error("You have a missing logic condition. Please update or delete it."); + return false; + } + + const thisLogic = `${logic.condition}-${logic.value}`; + if (existingLogicConditions.has(thisLogic)) { + setInvalidQuestions([question.id]); + toast.error("You have 2 competing logic conditons. Please update or delete one."); + return false; + } + existingLogicConditions.add(thisLogic); + } + } + + if ( + survey.redirectUrl && + !survey.redirectUrl.includes("https://") && + !survey.redirectUrl.includes("http://") + ) { + toast.error("Please enter a valid URL for redirecting respondents."); return false; } @@ -128,6 +197,10 @@ export default function SurveyMenuBar({ }; const saveSurveyAction = async (shouldNavigateBack = false) => { + if (localSurvey.questions.length === 0) { + toast.error("Please add at least one question."); + return; + } setIsMutatingSurvey(true); // Create a copy of localSurvey with isDraft removed from every question const strippedSurvey: TSurvey = { @@ -139,6 +212,7 @@ export default function SurveyMenuBar({ }; if (!validateSurvey(localSurvey)) { + setIsMutatingSurvey(false); return; } @@ -240,6 +314,7 @@ export default function SurveyMenuBar({ onClick={async () => { setIsMutatingSurvey(true); if (!validateSurvey(localSurvey)) { + setIsMutatingSurvey(false); return; } await updateSurveyAction({ ...localSurvey, status: "inProgress" }); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/UpdateQuestionId.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/UpdateQuestionId.tsx index 1b18a11d26..08650618e8 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/UpdateQuestionId.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/UpdateQuestionId.tsx @@ -7,6 +7,9 @@ import toast from "react-hot-toast"; export default function UpdateQuestionId({ localSurvey, question, questionIdx, updateQuestion }) { const [currentValue, setCurrentValue] = useState(question.id); const [prevValue, setPrevValue] = useState(question.id); + const [isInputInvalid, setIsInputInvalid] = useState( + currentValue.trim() === "" || currentValue.includes(" ") + ); const saveAction = () => { // return early if the input value was not changed @@ -14,28 +17,22 @@ export default function UpdateQuestionId({ localSurvey, question, questionIdx, u return; } - // check if id is unique const questionIds = localSurvey.questions.map((q) => q.id); if (questionIds.includes(currentValue)) { + setIsInputInvalid(true); toast.error("IDs have to be unique per survey."); - setCurrentValue(question.id); - return; - } - - // check if id contains any spaces - if (currentValue.trim() === "" || currentValue.includes(" ")) { - toast.error("ID should not contain space."); - setCurrentValue(question.id); - return; + } else if (currentValue.trim() === "" || currentValue.includes(" ")) { + setIsInputInvalid(true); + toast.error("ID should not be empty."); + } else { + setIsInputInvalid(false); + toast.success("Question ID updated."); } updateQuestion(questionIdx, { id: currentValue }); - toast.success("Question ID updated."); setPrevValue(currentValue); // after successful update, set current value as previous value }; - const isInputInvalid = currentValue.trim() === "" || currentValue.includes(" "); - return (
diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/Validation.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/Validation.ts index a2830b8f50..09eb880cf7 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/Validation.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/Validation.ts @@ -1,6 +1,7 @@ // extend this object in order to add more validation rules import { + TSurveyConsentQuestion, TSurveyMultipleChoiceMultiQuestion, TSurveyMultipleChoiceSingleQuestion, TSurveyQuestion, @@ -13,8 +14,16 @@ const validationRules = { multipleChoiceSingle: (question: TSurveyMultipleChoiceSingleQuestion) => { return !question.choices.some((element) => element.label.trim() === ""); }, + consent: (question: TSurveyConsentQuestion) => { + return question.label.trim() !== ""; + }, defaultValidation: (question: TSurveyQuestion) => { - return question.headline.trim() !== ""; + console.log(question); + return ( + question.headline.trim() !== "" && + question.buttonLabel?.trim() !== "" && + question.backButtonLabel?.trim() !== "" + ); }, };