Validate Survey Editor Form

Validate Survey Editor Form
This commit is contained in:
Johannes
2023-10-04 18:36:59 +05:45
committed by GitHub
7 changed files with 218 additions and 19 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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">&quot;Back&quot; 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>
)}

View File

@@ -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" });

View File

@@ -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>

View File

@@ -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() !== ""
);
},
};