mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-05 13:20:03 -06:00
Validate Survey Editor Form
Validate Survey Editor Form
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -33,6 +33,7 @@ export default function MultipleChoiceMultiForm({
|
||||
const [isNew, setIsNew] = useState(true);
|
||||
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
|
||||
const questionRef = useRef<HTMLInputElement>(null);
|
||||
const [isInvalidValue, setIsInvalidValue] = useState<string | null>(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 && (
|
||||
<TrashIcon
|
||||
|
||||
@@ -32,6 +32,7 @@ export default function MultipleChoiceSingleForm({
|
||||
const lastChoiceRef = useRef<HTMLInputElement>(null);
|
||||
const [isNew, setIsNew] = useState(true);
|
||||
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
|
||||
const [isInvalidValue, setIsInvalidValue] = useState<string | null>(null);
|
||||
const questionRef = useRef<HTMLInputElement>(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 && (
|
||||
<TrashIcon
|
||||
|
||||
@@ -42,7 +42,15 @@ interface QuestionCardProps {
|
||||
isInValid: boolean;
|
||||
}
|
||||
|
||||
export function BackButtonInput({ value, onChange }) {
|
||||
export function BackButtonInput({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
value: string | undefined;
|
||||
onChange: (e: any) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Label htmlFor="backButtonLabel">"Back" Button Label</Label>
|
||||
@@ -53,6 +61,7 @@ export function BackButtonInput({ value, onChange }) {
|
||||
value={value}
|
||||
placeholder="Back"
|
||||
onChange={onChange}
|
||||
className={className}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -235,9 +244,24 @@ export default function QuestionCard({
|
||||
<Input
|
||||
id="buttonLabel"
|
||||
name="buttonLabel"
|
||||
className={cn(
|
||||
isInValid &&
|
||||
question.backButtonLabel?.trim() === "" &&
|
||||
"border border-red-600 focus:border-red-600"
|
||||
)}
|
||||
value={question.buttonLabel}
|
||||
placeholder={lastQuestion ? "Finish" : "Next"}
|
||||
onChange={(e) => 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 });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,6 +269,11 @@ export default function QuestionCard({
|
||||
<BackButtonInput
|
||||
value={question.backButtonLabel}
|
||||
onChange={(e) => updateQuestion(questionIdx, { backButtonLabel: e.target.value })}
|
||||
className={cn(
|
||||
isInValid &&
|
||||
question.backButtonLabel?.trim() === "" &&
|
||||
"border border-red-600 focus:border-red-600"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -255,6 +284,11 @@ export default function QuestionCard({
|
||||
<BackButtonInput
|
||||
value={question.backButtonLabel}
|
||||
onChange={(e) => updateQuestion(questionIdx, { backButtonLabel: e.target.value })}
|
||||
className={cn(
|
||||
isInValid &&
|
||||
question.backButtonLabel?.trim() === "" &&
|
||||
"border border-red-600 focus:border-red-600"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<Label htmlFor="questionId">Question ID</Label>
|
||||
|
||||
@@ -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() !== ""
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user