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:
Dhruwang Jariwala
2024-04-24 11:07:32 +05:30
committed by GitHub
parent 3f9ff9c59b
commit 68c6dad26b
5 changed files with 306 additions and 315 deletions

View File

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

View File

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

View File

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

View File

@@ -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();

View File

@@ -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();