mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 18:30:32 -06:00
chore: Adds save button to question Id input and refactored survey saving/publishing logic (#2488)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com> Co-authored-by: Johannes <johannes@formbricks.com>
This commit is contained in:
committed by
GitHub
parent
3f9ff9c59b
commit
68c6dad26b
@@ -1,11 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
|
||||
import {
|
||||
isCardValid,
|
||||
isSurveyLogicCyclic,
|
||||
validateQuestion,
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/lib/validation";
|
||||
import { isSurveyValid } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/lib/validation";
|
||||
import { isEqual } from "lodash";
|
||||
import { AlertTriangleIcon, ArrowLeftIcon, SettingsIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -13,18 +9,9 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { createSegmentAction } from "@formbricks/ee/advancedTargeting/lib/actions";
|
||||
import { checkForEmptyFallBackValue } from "@formbricks/lib/utils/recall";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { ZSegmentFilters } from "@formbricks/types/segment";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyEditorTabs,
|
||||
TSurveyQuestionType,
|
||||
ZSurveyInlineTriggers,
|
||||
surveyHasBothTriggers,
|
||||
} from "@formbricks/types/surveys";
|
||||
import { TSurvey, TSurveyEditorTabs } from "@formbricks/types/surveys";
|
||||
import { AlertDialog } from "@formbricks/ui/AlertDialog";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
@@ -66,7 +53,7 @@ export const SurveyMenuBar = ({
|
||||
const [isSurveySaving, setIsSurveySaving] = useState(false);
|
||||
const cautionText = "This survey received responses, make changes with caution.";
|
||||
|
||||
let faultyQuestions: string[] = [];
|
||||
const faultyQuestions: string[] = [];
|
||||
|
||||
useEffect(() => {
|
||||
if (audiencePrompt && activeId === "settings") {
|
||||
@@ -129,269 +116,46 @@ export const SurveyMenuBar = ({
|
||||
}
|
||||
};
|
||||
|
||||
const validateSurvey = (survey: TSurvey) => {
|
||||
const existingQuestionIds = new Set();
|
||||
faultyQuestions = [];
|
||||
if (survey.questions.length === 0) {
|
||||
toast.error("Please add at least one question");
|
||||
return;
|
||||
}
|
||||
|
||||
if (survey.welcomeCard.enabled) {
|
||||
if (!isCardValid(survey.welcomeCard, "start", survey.languages)) {
|
||||
faultyQuestions.push("start");
|
||||
}
|
||||
}
|
||||
|
||||
if (survey.thankYouCard.enabled) {
|
||||
if (!isCardValid(survey.thankYouCard, "end", survey.languages)) {
|
||||
faultyQuestions.push("end");
|
||||
}
|
||||
}
|
||||
|
||||
let pin = survey?.pin;
|
||||
if (pin !== null && pin!.toString().length !== 4) {
|
||||
toast.error("PIN must be a four digit number.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (let index = 0; index < survey.questions.length; index++) {
|
||||
const question = survey.questions[index];
|
||||
const isFirstQuestion = index === 0;
|
||||
const isValid = validateQuestion(question, survey.languages, isFirstQuestion);
|
||||
|
||||
if (!isValid) {
|
||||
faultyQuestions.push(question.id);
|
||||
}
|
||||
}
|
||||
|
||||
// if there are any faulty questions, the user won't be allowed to save the survey
|
||||
if (faultyQuestions.length > 0) {
|
||||
setInvalidQuestions(faultyQuestions);
|
||||
setSelectedLanguageCode("default");
|
||||
toast.error("Please fill all required fields.");
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const question of survey.questions) {
|
||||
const existingLogicConditions = new Set();
|
||||
|
||||
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 === TSurveyQuestionType.MultipleChoiceSingle ||
|
||||
question.type === TSurveyQuestionType.MultipleChoiceMulti
|
||||
) {
|
||||
const haveSameChoices =
|
||||
question.choices.some((element) => element.label[selectedLanguageCode]?.trim() === "") ||
|
||||
question.choices.some((element, index) =>
|
||||
question.choices
|
||||
.slice(index + 1)
|
||||
.some(
|
||||
(nextElement) =>
|
||||
nextElement.label[selectedLanguageCode]?.trim() ===
|
||||
element.label[selectedLanguageCode].trim()
|
||||
)
|
||||
);
|
||||
|
||||
if (haveSameChoices) {
|
||||
toast.error("You have empty or duplicate choices.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (question.type === TSurveyQuestionType.Matrix) {
|
||||
const hasDuplicates = (labels: TI18nString[]) => {
|
||||
const flattenedLabels = labels
|
||||
.map((label) => Object.keys(label).map((lang) => `${lang}:${label[lang].trim().toLowerCase()}`))
|
||||
.flat();
|
||||
|
||||
return new Set(flattenedLabels).size !== flattenedLabels.length;
|
||||
};
|
||||
|
||||
// Function to check for empty labels in each language
|
||||
const hasEmptyLabels = (labels: TI18nString[]) => {
|
||||
return labels.some((label) => Object.values(label).some((value) => value.trim() === ""));
|
||||
};
|
||||
|
||||
if (hasEmptyLabels(question.rows) || hasEmptyLabels(question.columns)) {
|
||||
toast.error("Empty row or column labels in one or more languages");
|
||||
setInvalidQuestions([question.id]);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasDuplicates(question.rows)) {
|
||||
toast.error("You have duplicate row labels.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasDuplicates(question.columns)) {
|
||||
toast.error("You have duplicate column labels.");
|
||||
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: Fill or remove them in the Questions tab.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (question.required && logic.condition === "skipped") {
|
||||
toast.error("A logic condition is missing: Please update or delete it in the Questions tab.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const thisLogic = `${logic.condition}-${logic.value}`;
|
||||
if (existingLogicConditions.has(thisLogic)) {
|
||||
setInvalidQuestions([question.id]);
|
||||
toast.error(
|
||||
"There are two competing logic conditons: Please update or delete one in the Questions tab."
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const saveSurveyAction = async (shouldNavigateBack = false) => {
|
||||
if (localSurvey.questions.length === 0) {
|
||||
toast.error("Please add at least one question.");
|
||||
return;
|
||||
}
|
||||
|
||||
const questionWithEmptyFallback = checkForEmptyFallBackValue(localSurvey, selectedLanguageCode);
|
||||
if (questionWithEmptyFallback) {
|
||||
toast.error("Fallback missing");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSurveyLogicCyclic(localSurvey.questions)) {
|
||||
toast.error("Cyclic logic detected. Please fix it before saving.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSurveySaving(true);
|
||||
|
||||
// Create a copy of localSurvey with isDraft removed from every question
|
||||
let strippedSurvey: TSurvey = {
|
||||
...localSurvey,
|
||||
questions: localSurvey.questions.map((question) => {
|
||||
const { isDraft, ...rest } = question;
|
||||
return rest;
|
||||
}),
|
||||
};
|
||||
|
||||
if (!validateSurvey(localSurvey)) {
|
||||
setIsSurveySaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// validate the user segment filters
|
||||
const localSurveySegment = {
|
||||
id: strippedSurvey.segment?.id,
|
||||
filters: strippedSurvey.segment?.filters,
|
||||
title: strippedSurvey.segment?.title,
|
||||
description: strippedSurvey.segment?.description,
|
||||
};
|
||||
|
||||
const surveySegment = {
|
||||
id: survey.segment?.id,
|
||||
filters: survey.segment?.filters,
|
||||
title: survey.segment?.title,
|
||||
description: survey.segment?.description,
|
||||
};
|
||||
|
||||
// if the non-private segment in the survey and the strippedSurvey are different, don't save
|
||||
if (!strippedSurvey.segment?.isPrivate && !isEqual(localSurveySegment, surveySegment)) {
|
||||
toast.error("Please save the audience filters before saving the survey");
|
||||
setIsSurveySaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!!strippedSurvey.segment?.filters?.length) {
|
||||
const parsedFilters = ZSegmentFilters.safeParse(strippedSurvey.segment.filters);
|
||||
if (!parsedFilters.success) {
|
||||
const errMsg =
|
||||
parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message ||
|
||||
"Invalid targeting: Please check your audience filters";
|
||||
setIsSurveySaving(false);
|
||||
toast.error(errMsg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// if inlineTriggers are present validate with zod
|
||||
if (!!strippedSurvey.inlineTriggers) {
|
||||
const parsedInlineTriggers = ZSurveyInlineTriggers.safeParse(strippedSurvey.inlineTriggers);
|
||||
if (!parsedInlineTriggers.success) {
|
||||
toast.error("Invalid Custom Actions: Please check your custom actions");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// validate that both triggers and inlineTriggers are not present
|
||||
if (surveyHasBothTriggers(strippedSurvey)) {
|
||||
setIsSurveySaving(false);
|
||||
toast.error("Survey cannot have both custom and saved actions, please remove one.");
|
||||
return;
|
||||
}
|
||||
|
||||
strippedSurvey.triggers = strippedSurvey.triggers.filter((trigger) => Boolean(trigger));
|
||||
|
||||
// if the segment has id === "temp", we create a private segment with the same filters.
|
||||
if (strippedSurvey.type === "app" && strippedSurvey.segment?.id === "temp") {
|
||||
const { filters } = strippedSurvey.segment;
|
||||
|
||||
const parsedFilters = ZSegmentFilters.safeParse(filters);
|
||||
if (!parsedFilters.success) {
|
||||
const errMsg =
|
||||
parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message ||
|
||||
"Invalid targeting: Please check your audience filters";
|
||||
setIsSurveySaving(false);
|
||||
toast.error(errMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleSegmentWithIdTemp = async () => {
|
||||
if (localSurvey.segment && localSurvey.type === "app" && localSurvey.segment?.id === "temp") {
|
||||
const { filters } = localSurvey.segment;
|
||||
// create a new private segment
|
||||
const newSegment = await createSegmentAction({
|
||||
environmentId: environment.id,
|
||||
environmentId: localSurvey.environmentId,
|
||||
filters,
|
||||
isPrivate: true,
|
||||
surveyId: strippedSurvey.id,
|
||||
title: strippedSurvey.id,
|
||||
surveyId: localSurvey.id,
|
||||
title: localSurvey.id,
|
||||
});
|
||||
|
||||
strippedSurvey.segment = newSegment;
|
||||
return newSegment;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSurveySave = async (shouldNavigateBack = false) => {
|
||||
setIsSurveySaving(true);
|
||||
try {
|
||||
const udpatedSurvey = await updateSurveyAction({ ...strippedSurvey });
|
||||
|
||||
if (
|
||||
!isSurveyValid(
|
||||
localSurvey,
|
||||
faultyQuestions,
|
||||
setInvalidQuestions,
|
||||
selectedLanguageCode,
|
||||
setSelectedLanguageCode
|
||||
)
|
||||
) {
|
||||
setIsSurveySaving(false);
|
||||
return;
|
||||
}
|
||||
localSurvey.triggers = localSurvey.triggers.filter((trigger) => Boolean(trigger));
|
||||
localSurvey.questions = localSurvey.questions.map((question) => {
|
||||
const { isDraft, ...rest } = question;
|
||||
return rest;
|
||||
});
|
||||
const segment = (await handleSegmentWithIdTemp()) ?? null;
|
||||
await updateSurveyAction({ ...localSurvey, segment });
|
||||
setIsSurveySaving(false);
|
||||
setLocalSurvey({ ...strippedSurvey, segment: udpatedSurvey.segment });
|
||||
|
||||
setLocalSurvey(localSurvey);
|
||||
toast.success("Changes saved.");
|
||||
if (shouldNavigateBack) {
|
||||
router.back();
|
||||
@@ -405,48 +169,28 @@ export const SurveyMenuBar = ({
|
||||
};
|
||||
|
||||
const handleSurveyPublish = async () => {
|
||||
setIsSurveyPublishing(true);
|
||||
try {
|
||||
setIsSurveyPublishing(true);
|
||||
|
||||
if (isSurveyLogicCyclic(localSurvey.questions)) {
|
||||
toast.error("Cyclic logic detected. Please fix it before saving.");
|
||||
setIsSurveyPublishing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateSurvey(localSurvey)) {
|
||||
if (
|
||||
!isSurveyValid(
|
||||
localSurvey,
|
||||
faultyQuestions,
|
||||
setInvalidQuestions,
|
||||
selectedLanguageCode,
|
||||
setSelectedLanguageCode
|
||||
)
|
||||
) {
|
||||
setIsSurveyPublishing(false);
|
||||
return;
|
||||
}
|
||||
const status = localSurvey.runOnDate ? "scheduled" : "inProgress";
|
||||
|
||||
// if the segment has id === "temp", we create a private segment with the same filters.
|
||||
if (localSurvey.type === "app" && localSurvey.segment?.id === "temp") {
|
||||
const { filters } = localSurvey.segment;
|
||||
|
||||
const parsedFilters = ZSegmentFilters.safeParse(filters);
|
||||
if (!parsedFilters.success) {
|
||||
const errMsg =
|
||||
parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message ||
|
||||
"Invalid targeting: Please check your audience filters";
|
||||
setIsSurveySaving(false);
|
||||
toast.error(errMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
// create a new private segment
|
||||
const newSegment = await createSegmentAction({
|
||||
environmentId: environment.id,
|
||||
filters,
|
||||
isPrivate: true,
|
||||
surveyId: localSurvey.id,
|
||||
title: localSurvey.id,
|
||||
});
|
||||
|
||||
localSurvey.segment = newSegment;
|
||||
}
|
||||
|
||||
await updateSurveyAction({ ...localSurvey, status });
|
||||
const segment = (await handleSegmentWithIdTemp()) ?? null;
|
||||
await updateSurveyAction({
|
||||
...localSurvey,
|
||||
status,
|
||||
segment,
|
||||
});
|
||||
setIsSurveyPublishing(false);
|
||||
router.push(`/environments/${environment.id}/surveys/${localSurvey.id}/summary?success=true`);
|
||||
} catch (error) {
|
||||
toast.error("An error occured while publishing the survey.");
|
||||
@@ -513,7 +257,7 @@ export const SurveyMenuBar = ({
|
||||
variant={localSurvey.status === "draft" ? "secondary" : "darkCTA"}
|
||||
className="mr-3"
|
||||
loading={isSurveySaving}
|
||||
onClick={() => saveSurveyAction()}>
|
||||
onClick={() => handleSurveySave()}>
|
||||
Save
|
||||
</Button>
|
||||
{localSurvey.status === "draft" && audiencePrompt && (
|
||||
@@ -549,7 +293,7 @@ export const SurveyMenuBar = ({
|
||||
setConfirmDialogOpen(false);
|
||||
router.back();
|
||||
}}
|
||||
onConfirm={() => saveSurveyAction(true)}
|
||||
onConfirm={() => handleSurveySave(true)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
|
||||
@@ -45,10 +46,15 @@ export default function UpdateQuestionId({
|
||||
}
|
||||
};
|
||||
|
||||
const isButtonDisabled = () => {
|
||||
if (currentValue === question.id || currentValue.trim() === "") return true;
|
||||
else return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor="questionId">Question ID</Label>
|
||||
<div className="mt-2 inline-flex w-full">
|
||||
<div className="mt-2 inline-flex w-full space-x-2">
|
||||
<Input
|
||||
id="questionId"
|
||||
name="questionId"
|
||||
@@ -56,10 +62,12 @@ export default function UpdateQuestionId({
|
||||
onChange={(e) => {
|
||||
setCurrentValue(e.target.value);
|
||||
}}
|
||||
onBlur={saveAction}
|
||||
disabled={!(localSurvey.status === "draft" || question.isDraft)}
|
||||
className={isInputInvalid ? "border-red-300 focus:border-red-300" : ""}
|
||||
disabled={localSurvey.status !== "draft" && !question.isDraft}
|
||||
className={`h-10 ${isInputInvalid ? "border-red-300 focus:border-red-300" : ""}`}
|
||||
/>
|
||||
<Button variant="darkCTA" size="sm" onClick={saveAction} disabled={isButtonDisabled()}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
// extend this object in order to add more validation rules
|
||||
import { isEqual } from "lodash";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
import { extractLanguageCodes, getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { checkForEmptyFallBackValue } from "@formbricks/lib/utils/recall";
|
||||
import { ZSegmentFilters } from "@formbricks/types/segment";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
TSurveyCTAQuestion,
|
||||
TSurveyConsentQuestion,
|
||||
TSurveyLanguage,
|
||||
@@ -13,9 +17,12 @@ import {
|
||||
TSurveyOpenTextQuestion,
|
||||
TSurveyPictureSelectionQuestion,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionType,
|
||||
TSurveyQuestions,
|
||||
TSurveyThankYouCard,
|
||||
TSurveyWelcomeCard,
|
||||
ZSurveyInlineTriggers,
|
||||
surveyHasBothTriggers,
|
||||
} from "@formbricks/types/surveys";
|
||||
|
||||
// Utility function to check if label is valid for all required languages
|
||||
@@ -266,3 +273,231 @@ export const isSurveyLogicCyclic = (questions: TSurveyQuestions) => {
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const isSurveyValid = (
|
||||
survey: TSurvey,
|
||||
faultyQuestions: string[],
|
||||
setInvalidQuestions: (questions: string[]) => void,
|
||||
selectedLanguageCode: string,
|
||||
setSelectedLanguageCode: (languageCode: string) => void
|
||||
) => {
|
||||
const existingQuestionIds = new Set();
|
||||
|
||||
// Ensuring at least one question is added to the survey.
|
||||
if (survey.questions.length === 0) {
|
||||
toast.error("Please add at least one question");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Checking the validity of the welcome and thank-you cards if they are enabled.
|
||||
if (survey.welcomeCard.enabled) {
|
||||
if (!isCardValid(survey.welcomeCard, "start", survey.languages)) {
|
||||
faultyQuestions.push("start");
|
||||
}
|
||||
}
|
||||
|
||||
if (survey.thankYouCard.enabled) {
|
||||
if (!isCardValid(survey.thankYouCard, "end", survey.languages)) {
|
||||
faultyQuestions.push("end");
|
||||
}
|
||||
}
|
||||
|
||||
// Verifying that any provided PIN is exactly four digits long.
|
||||
const pin = survey.pin;
|
||||
if (pin && pin.toString().length !== 4) {
|
||||
toast.error("PIN must be a four digit number.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Assessing each question for completeness and correctness,
|
||||
for (let index = 0; index < survey.questions.length; index++) {
|
||||
const question = survey.questions[index];
|
||||
const isFirstQuestion = index === 0;
|
||||
const isValid = validateQuestion(question, survey.languages, isFirstQuestion);
|
||||
|
||||
if (!isValid) {
|
||||
faultyQuestions.push(question.id);
|
||||
}
|
||||
}
|
||||
|
||||
// if there are any faulty questions, the user won't be allowed to save the survey
|
||||
if (faultyQuestions.length > 0) {
|
||||
setInvalidQuestions(faultyQuestions);
|
||||
setSelectedLanguageCode("default");
|
||||
toast.error("Please fill all required fields.");
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const question of survey.questions) {
|
||||
const existingLogicConditions = new Set();
|
||||
|
||||
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 === TSurveyQuestionType.MultipleChoiceSingle ||
|
||||
question.type === TSurveyQuestionType.MultipleChoiceMulti
|
||||
) {
|
||||
const haveSameChoices =
|
||||
question.choices.some((element) => element.label[selectedLanguageCode]?.trim() === "") ||
|
||||
question.choices.some((element, index) =>
|
||||
question.choices
|
||||
.slice(index + 1)
|
||||
.some(
|
||||
(nextElement) =>
|
||||
nextElement.label[selectedLanguageCode]?.trim() === element.label[selectedLanguageCode].trim()
|
||||
)
|
||||
);
|
||||
|
||||
if (haveSameChoices) {
|
||||
toast.error("You have empty or duplicate choices.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (question.type === TSurveyQuestionType.Matrix) {
|
||||
const hasDuplicates = (labels: TI18nString[]) => {
|
||||
const flattenedLabels = labels
|
||||
.map((label) => Object.keys(label).map((lang) => `${lang}:${label[lang].trim().toLowerCase()}`))
|
||||
.flat();
|
||||
|
||||
return new Set(flattenedLabels).size !== flattenedLabels.length;
|
||||
};
|
||||
|
||||
// Function to check for empty labels in each language
|
||||
const hasEmptyLabels = (labels: TI18nString[]) => {
|
||||
return labels.some((label) => Object.values(label).some((value) => value.trim() === ""));
|
||||
};
|
||||
|
||||
if (hasEmptyLabels(question.rows) || hasEmptyLabels(question.columns)) {
|
||||
toast.error("Empty row or column labels in one or more languages");
|
||||
setInvalidQuestions([question.id]);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasDuplicates(question.rows)) {
|
||||
toast.error("You have duplicate row labels.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasDuplicates(question.columns)) {
|
||||
toast.error("You have duplicate column labels.");
|
||||
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: Fill or remove them in the Questions tab.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (question.required && logic.condition === "skipped") {
|
||||
toast.error("A logic condition is missing: Please update or delete it in the Questions tab.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const thisLogic = `${logic.condition}-${logic.value}`;
|
||||
if (existingLogicConditions.has(thisLogic)) {
|
||||
setInvalidQuestions([question.id]);
|
||||
toast.error(
|
||||
"There are two competing logic conditons: Please update or delete one in the Questions tab."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
existingLogicConditions.add(thisLogic);
|
||||
}
|
||||
}
|
||||
|
||||
// Checking the validity of redirection URLs to ensure they are properly formatted.
|
||||
if (
|
||||
survey.redirectUrl &&
|
||||
!survey.redirectUrl.includes("https://") &&
|
||||
!survey.redirectUrl.includes("http://")
|
||||
) {
|
||||
toast.error("Please enter a valid URL for redirecting respondents.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// validate the user segment filters
|
||||
const localSurveySegment = {
|
||||
id: survey.segment?.id,
|
||||
filters: survey.segment?.filters,
|
||||
title: survey.segment?.title,
|
||||
description: survey.segment?.description,
|
||||
};
|
||||
|
||||
const surveySegment = {
|
||||
id: survey.segment?.id,
|
||||
filters: survey.segment?.filters,
|
||||
title: survey.segment?.title,
|
||||
description: survey.segment?.description,
|
||||
};
|
||||
|
||||
// if the non-private segment in the survey and the strippedSurvey are different, don't save
|
||||
if (!survey.segment?.isPrivate && !isEqual(localSurveySegment, surveySegment)) {
|
||||
toast.error("Please save the audience filters before saving the survey");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!!survey.segment?.filters?.length) {
|
||||
const parsedFilters = ZSegmentFilters.safeParse(survey.segment.filters);
|
||||
if (!parsedFilters.success) {
|
||||
const errMsg =
|
||||
parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message ||
|
||||
"Invalid targeting: Please check your audience filters";
|
||||
toast.error(errMsg);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// if inlineTriggers are present validate with zod
|
||||
if (!!survey.inlineTriggers) {
|
||||
const parsedInlineTriggers = ZSurveyInlineTriggers.safeParse(survey.inlineTriggers);
|
||||
if (!parsedInlineTriggers.success) {
|
||||
toast.error("Invalid Custom Actions: Please check your custom actions");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// validate that both triggers and inlineTriggers are not present
|
||||
if (surveyHasBothTriggers(survey)) {
|
||||
toast.error("Survey cannot have both custom and saved actions, please remove one.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const questionWithEmptyFallback = checkForEmptyFallBackValue(survey, selectedLanguageCode);
|
||||
if (questionWithEmptyFallback) {
|
||||
toast.error("Fallback missing");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Detecting any cyclic dependencies in survey logic.
|
||||
if (isSurveyLogicCyclic(survey.questions)) {
|
||||
toast.error("Cyclic logic detected. Please fix it before saving.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (survey.type === "app" && survey.segment?.id === "temp") {
|
||||
const { filters } = survey.segment;
|
||||
|
||||
const parsedFilters = ZSegmentFilters.safeParse(filters);
|
||||
if (!parsedFilters.success) {
|
||||
const errMsg =
|
||||
parsedFilters.error.issues.find((issue) => issue.code === "custom")?.message ||
|
||||
"Invalid targeting: Please check your audience filters";
|
||||
toast.error(errMsg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -14,7 +14,8 @@ test.describe("Onboarding Flow Test", async () => {
|
||||
|
||||
await page.getByRole("button", { name: "Link Surveys Create a new" }).click();
|
||||
await page.getByRole("button", { name: "Collect Feedback Collect" }).click();
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
await page.getByRole("button", { name: "Continue to Settings" }).click();
|
||||
await page.getByRole("button", { name: "Publish" }).click();
|
||||
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
|
||||
await expect(page.getByText(productName)).toBeVisible();
|
||||
|
||||
@@ -416,7 +416,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
|
||||
throw new ResourceNotFoundError("Survey", surveyId);
|
||||
}
|
||||
|
||||
const { triggers, environmentId, segment, languages, type, ...surveyData } = updatedSurvey;
|
||||
const { triggers, environmentId, segment, questions, languages, type, ...surveyData } = updatedSurvey;
|
||||
|
||||
if (languages) {
|
||||
// Process languages update logic here
|
||||
@@ -469,7 +469,6 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
|
||||
if (triggers) {
|
||||
data.triggers = processTriggerUpdates(triggers, currentSurvey.triggers, actionClasses);
|
||||
}
|
||||
|
||||
// if the survey body has type other than "app" but has a private segment, we delete that segment, and if it has a public segment, we disconnect from to the survey
|
||||
if (segment) {
|
||||
if (type === "app") {
|
||||
@@ -507,6 +506,10 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
|
||||
environmentId: segment.environmentId,
|
||||
});
|
||||
}
|
||||
data.questions = questions.map((question) => {
|
||||
const { isDraft, ...rest } = question;
|
||||
return rest;
|
||||
});
|
||||
|
||||
surveyData.updatedAt = new Date();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user