chore: rewrite survey editor to server components (#728)

* created a new service for survey data mutation

* made requested changes

* made some refactors

* ran pnpm format

* removed console logs

* removed some unused code

* made upateSurvey return TSurvey and added laoding state to AddNoCodeActionModal

* fixed minor bugs

* ran pnpm format

* fixed build issues

* Replaced old question types with new types

* fix survey list not up to date on changes

* solved back button issue

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Dhruwang Jariwala
2023-09-13 15:00:50 +05:30
committed by GitHub
parent ffef88731d
commit 993346b9ae
42 changed files with 597 additions and 313 deletions

View File

@@ -8,8 +8,11 @@ import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { testURLmatch } from "./testURLmatch";
import { createActionClass } from "@formbricks/lib/services/actionClass";
import { TActionClassInput, TActionClassNoCodeConfig } from "@formbricks/types/v1/actionClasses";
import { useRouter } from "next/navigation";
import {
TActionClassInput,
TActionClassNoCodeConfig,
TActionClass,
} from "@formbricks/types/v1/actionClasses";
import { CssSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/CssSelector";
import { PageUrlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/PageUrlSelector";
import { InnerHtmlSelector } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/(selectors)/InnerHtmlSelector";
@@ -18,10 +21,15 @@ interface AddNoCodeActionModalProps {
environmentId: string;
open: boolean;
setOpen: (v: boolean) => void;
setActionClassArray?;
}
export default function AddNoCodeActionModal({ environmentId, open, setOpen }: AddNoCodeActionModalProps) {
const router = useRouter();
export default function AddNoCodeActionModal({
environmentId,
open,
setOpen,
setActionClassArray,
}: AddNoCodeActionModalProps) {
const { register, control, handleSubmit, watch, reset } = useForm();
const [isPageUrl, setIsPageUrl] = useState(false);
const [isCssSelector, setIsCssSelector] = useState(false);
@@ -75,8 +83,13 @@ export default function AddNoCodeActionModal({ environmentId, open, setOpen }: A
type: "noCode",
} as TActionClassInput;
await createActionClass(environmentId, updatedData);
router.refresh();
const newActionClass: TActionClass = await createActionClass(environmentId, updatedData);
if (setActionClassArray) {
setActionClassArray((prevActionClassArray: TActionClass[]) => [
...prevActionClassArray,
newActionClass,
]);
}
reset();
resetAllStates(false);
toast.success("Action added successfully.");

View File

@@ -1,5 +1,4 @@
"use client";
import Modal from "@/components/preview/Modal";
import TabOption from "@/components/preview/TabOption";
import { SurveyInline } from "@/components/shared/Survey";
@@ -16,7 +15,6 @@ interface PreviewSurveyProps {
survey: TSurvey | Survey;
setActiveQuestionId: (id: string | null) => void;
activeQuestionId?: string | null;
environmentId: string;
previewType?: "modal" | "fullwidth" | "email";
product: TProduct;
environment: TEnvironment;

View File

@@ -21,7 +21,7 @@ export default async function SurveysList({ environmentId }: { environmentId: st
const environment = await getEnvironment(environmentId);
const surveys: TSurveyWithAnalytics[] = await getSurveysWithAnalytics(environmentId);
const environments: TEnvironment[] = await getEnvironments(product.id);
const otherEnvironment = environments.find((e) => e.type !== environment.type);
const otherEnvironment = environments.find((e) => e.type !== environment.type)!;
const totalSubmissions = surveys.reduce((acc, survey) => acc + (survey.analytics?.numResponses || 0), 0);
if (surveys.length === 0) {

View File

@@ -1,11 +1,11 @@
import { CTAQuestion } from "@formbricks/types/questions";
import type { QuestionSummary } from "@formbricks/types/responses";
import { TSurveyCTAQuestion } from "@formbricks/types/v1/surveys";
import { ProgressBar } from "@formbricks/ui";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
interface CTASummaryProps {
questionSummary: QuestionSummary<CTAQuestion>;
questionSummary: QuestionSummary<TSurveyCTAQuestion>;
}
interface ChoiceResult {

View File

@@ -1,11 +1,11 @@
import { ConsentQuestion } from "@formbricks/types/questions";
import type { QuestionSummary } from "@formbricks/types/responses";
import { ProgressBar } from "@formbricks/ui";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
import { TSurveyConsentQuestion } from "@formbricks/types/v1/surveys";
interface ConsentSummaryProps {
questionSummary: QuestionSummary<ConsentQuestion>;
questionSummary: QuestionSummary<TSurveyConsentQuestion>;
}
interface ChoiceResult {

View File

@@ -1,17 +1,17 @@
import {
MultipleChoiceMultiQuestion,
MultipleChoiceSingleQuestion,
QuestionType,
} from "@formbricks/types/questions";
import { QuestionType } from "@formbricks/types/questions";
import type { QuestionSummary } from "@formbricks/types/responses";
import { PersonAvatar, ProgressBar } from "@formbricks/ui";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
import Link from "next/link";
import { truncate } from "@/lib/utils";
import {
TSurveyMultipleChoiceMultiQuestion,
TSurveyMultipleChoiceSingleQuestion,
} from "@formbricks/types/v1/surveys";
interface MultipleChoiceSummaryProps {
questionSummary: QuestionSummary<MultipleChoiceMultiQuestion | MultipleChoiceSingleQuestion>;
questionSummary: QuestionSummary<TSurveyMultipleChoiceMultiQuestion | TSurveyMultipleChoiceSingleQuestion>;
environmentId: string;
surveyType: string;
}

View File

@@ -1,11 +1,11 @@
import { NPSQuestion } from "@formbricks/types/questions";
import type { QuestionSummary } from "@formbricks/types/responses";
import { TSurveyNPSQuestion } from "@formbricks/types/v1/surveys";
import { HalfCircle, ProgressBar } from "@formbricks/ui";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
interface NPSSummaryProps {
questionSummary: QuestionSummary<NPSQuestion>;
questionSummary: QuestionSummary<TSurveyNPSQuestion>;
}
interface Result {

View File

@@ -1,13 +1,13 @@
import { truncate } from "@/lib/utils";
import { timeSince } from "@formbricks/lib/time";
import { OpenTextQuestion } from "@formbricks/types/questions";
import type { QuestionSummary } from "@formbricks/types/responses";
import { TSurveyOpenTextQuestion } from "@formbricks/types/v1/surveys";
import { PersonAvatar } from "@formbricks/ui";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
interface OpenTextSummaryProps {
questionSummary: QuestionSummary<OpenTextQuestion>;
questionSummary: QuestionSummary<TSurveyOpenTextQuestion>;
environmentId: string;
}

View File

@@ -3,10 +3,11 @@ import { ProgressBar } from "@formbricks/ui";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
import { RatingResponse } from "../RatingResponse";
import { QuestionType, RatingQuestion } from "@formbricks/types/questions";
import { QuestionType } from "@formbricks/types/questions";
import { TSurveyRatingQuestion } from "@formbricks/types/v1/surveys";
interface RatingSummaryProps {
questionSummary: QuestionSummary<RatingQuestion>;
questionSummary: QuestionSummary<TSurveyRatingQuestion>;
}
interface ChoiceResult {

View File

@@ -1,18 +1,19 @@
import ConsentSummary from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/ConsentSummary";
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import {
QuestionType,
type CTAQuestion,
type ConsentQuestion,
type MultipleChoiceMultiQuestion,
type MultipleChoiceSingleQuestion,
type NPSQuestion,
type OpenTextQuestion,
type RatingQuestion,
} from "@formbricks/types/questions";
import { QuestionType } from "@formbricks/types/questions";
import type { QuestionSummary } from "@formbricks/types/responses";
import { TResponse } from "@formbricks/types/v1/responses";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/v1/surveys";
import {
TSurvey,
TSurveyCTAQuestion,
TSurveyConsentQuestion,
TSurveyMultipleChoiceMultiQuestion,
TSurveyMultipleChoiceSingleQuestion,
TSurveyNPSQuestion,
TSurveyOpenTextQuestion,
TSurveyQuestion,
TSurveyRatingQuestion,
} from "@formbricks/types/v1/surveys";
import CTASummary from "./CTASummary";
import MultipleChoiceSummary from "./MultipleChoiceSummary";
import NPSSummary from "./NPSSummary";
@@ -58,7 +59,7 @@ export default function SummaryList({ environmentId, survey, responses }: Summar
return (
<OpenTextSummary
key={questionSummary.question.id}
questionSummary={questionSummary as QuestionSummary<OpenTextQuestion>}
questionSummary={questionSummary as QuestionSummary<TSurveyOpenTextQuestion>}
environmentId={environmentId}
/>
);
@@ -72,7 +73,7 @@ export default function SummaryList({ environmentId, survey, responses }: Summar
key={questionSummary.question.id}
questionSummary={
questionSummary as QuestionSummary<
MultipleChoiceMultiQuestion | MultipleChoiceSingleQuestion
TSurveyMultipleChoiceMultiQuestion | TSurveyMultipleChoiceSingleQuestion
>
}
environmentId={environmentId}
@@ -84,7 +85,7 @@ export default function SummaryList({ environmentId, survey, responses }: Summar
return (
<NPSSummary
key={questionSummary.question.id}
questionSummary={questionSummary as QuestionSummary<NPSQuestion>}
questionSummary={questionSummary as QuestionSummary<TSurveyNPSQuestion>}
/>
);
}
@@ -92,7 +93,7 @@ export default function SummaryList({ environmentId, survey, responses }: Summar
return (
<CTASummary
key={questionSummary.question.id}
questionSummary={questionSummary as QuestionSummary<CTAQuestion>}
questionSummary={questionSummary as QuestionSummary<TSurveyCTAQuestion>}
/>
);
}
@@ -100,7 +101,7 @@ export default function SummaryList({ environmentId, survey, responses }: Summar
return (
<RatingSummary
key={questionSummary.question.id}
questionSummary={questionSummary as QuestionSummary<RatingQuestion>}
questionSummary={questionSummary as QuestionSummary<TSurveyRatingQuestion>}
/>
);
}
@@ -108,7 +109,7 @@ export default function SummaryList({ environmentId, survey, responses }: Summar
return (
<ConsentSummary
key={questionSummary.question.id}
questionSummary={questionSummary as QuestionSummary<ConsentQuestion>}
questionSummary={questionSummary as QuestionSummary<TSurveyConsentQuestion>}
/>
);
}

View File

@@ -1,13 +1,12 @@
import React from "react";
import LogicEditor from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/LogicEditor";
import UpdateQuestionId from "./UpdateQuestionId";
import { Question } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import { TSurveyQuestion, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
interface AdvancedSettingsProps {
question: Question;
question: TSurveyQuestion;
questionIdx: number;
localSurvey: Survey;
localSurvey: TSurveyWithAnalytics;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
}

View File

@@ -1,15 +1,14 @@
"use client";
import { md } from "@formbricks/lib/markdownIt";
import type { CTAQuestion } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import { TSurveyCTAQuestion, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { Editor, Input, Label, RadioGroup, RadioGroupItem } from "@formbricks/ui";
import { useState } from "react";
import { BackButtonInput } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/QuestionCard";
interface CTAQuestionFormProps {
localSurvey: Survey;
question: CTAQuestion;
localSurvey: TSurveyWithAnalytics;
question: TSurveyCTAQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;

View File

@@ -1,14 +1,13 @@
"use client";
import { md } from "@formbricks/lib/markdownIt";
import type { ConsentQuestion } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import { TSurveyConsentQuestion, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { Editor, Input, Label } from "@formbricks/ui";
import { useState } from "react";
interface ConsentQuestionFormProps {
localSurvey: Survey;
question: ConsentQuestion;
localSurvey: TSurveyWithAnalytics;
question: TSurveyConsentQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
isInValid: boolean;

View File

@@ -1,13 +1,13 @@
"use client";
import { cn } from "@formbricks/lib/cn";
import type { Survey } from "@formbricks/types/surveys";
import { Input, Label, Switch } from "@formbricks/ui";
import * as Collapsible from "@radix-ui/react-collapsible";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
interface EditThankYouCardProps {
localSurvey: Survey;
setLocalSurvey: (survey: Survey) => void;
localSurvey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
setActiveQuestionId: (id: string | null) => void;
activeQuestionId: string | null;
}
@@ -41,7 +41,7 @@ export default function EditThankYouCard({
return (
<div
className={cn(
open ? "scale-100 shadow-lg" : "scale-97 shadow-md",
open ? "scale-100 shadow-lg " : "scale-97 shadow-md",
"flex flex-row rounded-lg bg-white transition-transform duration-300 ease-in-out"
)}>
<div
@@ -86,7 +86,7 @@ export default function EditThankYouCard({
)}
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className="px-4 pb-4">
<Collapsible.CollapsibleContent className="px-4 pb-6">
<form>
<div className="mt-3">
<Label htmlFor="headline">Headline</Label>

View File

@@ -1,8 +1,5 @@
"use client";
import { useEnvironment } from "@/lib/environments/environments";
import { cn } from "@formbricks/lib/cn";
import type { Survey } from "@formbricks/types/surveys";
import { Badge, Label, RadioGroup, RadioGroupItem } from "@formbricks/ui";
import {
CheckCircleIcon,
@@ -15,17 +12,18 @@ import {
import * as Collapsible from "@radix-ui/react-collapsible";
import Link from "next/link";
import { useEffect, useState } from "react";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { TEnvironment } from "@formbricks/types/v1/environment";
interface HowToSendCardProps {
localSurvey: Survey;
setLocalSurvey: (survey: Survey) => void;
environmentId: string;
localSurvey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
environment: TEnvironment;
}
export default function HowToSendCard({ localSurvey, setLocalSurvey, environmentId }: HowToSendCardProps) {
export default function HowToSendCard({ localSurvey, setLocalSurvey, environment }: HowToSendCardProps) {
const [open, setOpen] = useState(localSurvey.type === "web" ? false : true);
const [widgetSetupCompleted, setWidgetSetupCompleted] = useState(false);
const { environment } = useEnvironment(environmentId);
useEffect(() => {
if (environment && environment.widgetSetupCompleted) {
@@ -150,7 +148,7 @@ export default function HowToSendCard({ localSurvey, setLocalSurvey, environment
<p className="text-xs font-normal">
Follow the{" "}
<Link
href={`/environments/${environmentId}/settings/setup`}
href={`/environments/${environment.id}/settings/setup`}
className="underline hover:text-amber-900"
target="_blank">
set up guide

View File

@@ -1,5 +1,5 @@
import { Logic, LogicCondition, Question, QuestionType } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import { LogicCondition, QuestionType } from "@formbricks/types/questions";
import { TSurveyLogic, TSurveyQuestion, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import {
Button,
DropdownMenu,
@@ -23,9 +23,9 @@ import { useMemo } from "react";
import { BsArrowDown, BsArrowReturnRight } from "react-icons/bs";
interface LogicEditorProps {
localSurvey: Survey;
localSurvey: TSurveyWithAnalytics;
questionIdx: number;
question: Question;
question: TSurveyQuestion;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
}
@@ -48,7 +48,7 @@ export default function LogicEditor({
if ("choices" in question) {
return question.choices.map((choice) => choice.label);
} else if ("range" in question) {
return Array.from({ length: question.range }, (_, i) => (i + 1).toString());
return Array.from({ length: question.range ? question.range : 0 }, (_, i) => (i + 1).toString());
} else if (question.type === QuestionType.NPS) {
return Array.from({ length: 11 }, (_, i) => (i + 0).toString());
}
@@ -141,7 +141,7 @@ export default function LogicEditor({
};
const addLogic = () => {
const newLogic: Logic[] = !question.logic ? [] : question.logic;
const newLogic: TSurveyLogic[] = !question.logic ? [] : question.logic;
newLogic.push({
condition: undefined,
value: undefined,

View File

@@ -1,5 +1,3 @@
import type { MultipleChoiceMultiQuestion } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import {
Button,
Input,
@@ -14,10 +12,11 @@ import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid";
import { createId } from "@paralleldrive/cuid2";
import { cn } from "@formbricks/lib/cn";
import { useEffect, useRef, useState } from "react";
import { TSurveyMultipleChoiceMultiQuestion, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
interface OpenQuestionFormProps {
localSurvey: Survey;
question: MultipleChoiceMultiQuestion;
localSurvey: TSurveyWithAnalytics;
question: TSurveyMultipleChoiceMultiQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;

View File

@@ -1,5 +1,3 @@
import type { MultipleChoiceSingleQuestion } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import {
Button,
Input,
@@ -14,10 +12,11 @@ import { TrashIcon, PlusIcon } from "@heroicons/react/24/solid";
import { createId } from "@paralleldrive/cuid2";
import { cn } from "@formbricks/lib/cn";
import { useEffect, useRef, useState } from "react";
import { TSurveyMultipleChoiceSingleQuestion, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
interface OpenQuestionFormProps {
localSurvey: Survey;
question: MultipleChoiceSingleQuestion;
localSurvey: TSurveyWithAnalytics;
question: TSurveyMultipleChoiceSingleQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;

View File

@@ -1,12 +1,11 @@
import type { NPSQuestion } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import { TSurveyNPSQuestion, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { Button, Input, Label } from "@formbricks/ui";
import { TrashIcon, PlusIcon } from "@heroicons/react/24/solid";
import { useState } from "react";
interface NPSQuestionFormProps {
localSurvey: Survey;
question: NPSQuestion;
localSurvey: TSurveyWithAnalytics;
question: TSurveyNPSQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;

View File

@@ -1,12 +1,11 @@
import type { OpenTextQuestion } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import { TSurveyOpenTextQuestion, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { Button, Input, Label } from "@formbricks/ui";
import { TrashIcon, PlusIcon } from "@heroicons/react/24/solid";
import { useState } from "react";
interface OpenQuestionFormProps {
localSurvey: Survey;
question: OpenTextQuestion;
localSurvey: TSurveyWithAnalytics;
question: TSurveyOpenTextQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;

View File

@@ -4,7 +4,6 @@ import AdvancedSettings from "@/app/(app)/environments/[environmentId]/surveys/[
import { getQuestionTypeName } from "@/lib/questions";
import { cn } from "@formbricks/lib/cn";
import { QuestionType } from "@formbricks/types/questions";
import type { Survey } from "@formbricks/types/surveys";
import { Input, Label, Switch } from "@formbricks/ui";
import {
ChatBubbleBottomCenterTextIcon,
@@ -28,9 +27,10 @@ import NPSQuestionForm from "./NPSQuestionForm";
import OpenQuestionForm from "./OpenQuestionForm";
import QuestionDropdown from "./QuestionMenu";
import RatingQuestionForm from "./RatingQuestionForm";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
interface QuestionCardProps {
localSurvey: Survey;
localSurvey: TSurveyWithAnalytics;
questionIdx: number;
moveQuestion: (questionIndex: number, up: boolean) => void;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;

View File

@@ -1,6 +1,6 @@
"use client";
import type { Survey } from "@formbricks/types/surveys";
import React from "react";
import { createId } from "@paralleldrive/cuid2";
import { useMemo, useState } from "react";
import { DragDropContext } from "react-beautiful-dnd";
@@ -11,10 +11,11 @@ import QuestionCard from "./QuestionCard";
import { StrictModeDroppable } from "./StrictModeDroppable";
import { Question } from "@formbricks/types/questions";
import { validateQuestion } from "./Validation";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
interface QuestionsViewProps {
localSurvey: Survey;
setLocalSurvey: (survey: Survey) => void;
localSurvey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
activeQuestionId: string | null;
setActiveQuestionId: (questionId: string | null) => void;
environmentId: string;
@@ -40,7 +41,11 @@ export default function QuestionsView({
const [backButtonLabel, setbackButtonLabel] = useState(null);
const handleQuestionLogicChange = (survey: Survey, compareId: string, updatedId: string): Survey => {
const handleQuestionLogicChange = (
survey: TSurveyWithAnalytics,
compareId: string,
updatedId: string
): TSurveyWithAnalytics => {
survey.questions.forEach((question) => {
if (!question.logic) return;
question.logic.forEach((rule) => {
@@ -105,7 +110,7 @@ export default function QuestionsView({
const deleteQuestion = (questionIdx: number) => {
const questionId = localSurvey.questions[questionIdx].id;
let updatedSurvey: Survey = JSON.parse(JSON.stringify(localSurvey));
let updatedSurvey: TSurveyWithAnalytics = JSON.parse(JSON.stringify(localSurvey));
updatedSurvey.questions.splice(questionIdx, 1);
updatedSurvey = handleQuestionLogicChange(updatedSurvey, questionId, "end");

View File

@@ -1,14 +1,13 @@
import type { RatingQuestion } from "@formbricks/types/questions";
import type { Survey } from "@formbricks/types/surveys";
import { Button, Input, Label } from "@formbricks/ui";
import { FaceSmileIcon, HashtagIcon, StarIcon } from "@heroicons/react/24/outline";
import Dropdown from "./RatingTypeDropdown";
import { TrashIcon, PlusIcon } from "@heroicons/react/24/solid";
import { useState } from "react";
import { TSurveyRatingQuestion, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
interface RatingQuestionFormProps {
localSurvey: Survey;
question: RatingQuestion;
localSurvey: TSurveyWithAnalytics;
question: TSurveyRatingQuestion;
questionIdx: number;
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
lastQuestion: boolean;

View File

@@ -1,7 +1,7 @@
"use client";
import { cn } from "@formbricks/lib/cn";
import type { Survey } from "@formbricks/types/surveys";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { AdvancedOptionToggle, Badge, Input, Label, RadioGroup, RadioGroupItem } from "@formbricks/ui";
import { CheckCircleIcon } from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
@@ -33,8 +33,8 @@ const displayOptions: DisplayOption[] = [
];
interface RecontactOptionsCardProps {
localSurvey: Survey;
setLocalSurvey: (survey: Survey) => void;
localSurvey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
environmentId: string;
}

View File

@@ -1,6 +1,6 @@
"use client";
import type { Survey } from "@formbricks/types/surveys";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { AdvancedOptionToggle, DatePicker, Input, Label } from "@formbricks/ui";
import { CheckCircleIcon } from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
@@ -8,8 +8,8 @@ import { useEffect, useState } from "react";
import toast from "react-hot-toast";
interface ResponseOptionsCardProps {
localSurvey: Survey;
setLocalSurvey: (survey: Survey) => void;
localSurvey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
}
export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: ResponseOptionsCardProps) {
@@ -17,7 +17,7 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
const autoComplete = localSurvey.autoComplete !== null;
const [redirectToggle, setRedirectToggle] = useState(false);
const [surveyCloseOnDateToggle, setSurveyCloseOnDateToggle] = useState(false);
useState;
const [redirectUrl, setRedirectUrl] = useState<string | null>("");
const [surveyClosedMessageToggle, setSurveyClosedMessageToggle] = useState(false);
const [verifyEmailToggle, setVerifyEmailToggle] = useState(false);
@@ -95,6 +95,7 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
subheading?: string;
}) => {
const message = {
enabled: surveyCloseOnDateToggle,
heading: heading ?? surveyClosedMessage.heading,
subheading: subheading ?? surveyClosedMessage.subheading,
};
@@ -149,16 +150,16 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
const handleCheckMark = () => {
if (autoComplete) {
const updatedSurvey: Survey = { ...localSurvey, autoComplete: null };
const updatedSurvey: TSurveyWithAnalytics = { ...localSurvey, autoComplete: null };
setLocalSurvey(updatedSurvey);
} else {
const updatedSurvey: Survey = { ...localSurvey, autoComplete: 25 };
const updatedSurvey: TSurveyWithAnalytics = { ...localSurvey, autoComplete: 25 };
setLocalSurvey(updatedSurvey);
}
};
const handleInputResponse = (e) => {
const updatedSurvey: Survey = { ...localSurvey, autoComplete: parseInt(e.target.value) };
const updatedSurvey: TSurveyWithAnalytics = { ...localSurvey, autoComplete: parseInt(e.target.value) };
setLocalSurvey(updatedSurvey);
};
@@ -168,11 +169,11 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
return;
}
const inputResponses = localSurvey?._count?.responses || 0;
const inputResponses = localSurvey.analytics.numResponses || 0;
if (parseInt(e.target.value) <= inputResponses) {
toast.error(
`Response limit needs to exceed number of received responses (${localSurvey?._count?.responses}).`
`Response limit needs to exceed number of received responses (${localSurvey.analytics.numResponses}).`
);
return;
}
@@ -211,7 +212,11 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
<Input
autoFocus
type="number"
min={localSurvey?._count?.responses ? (localSurvey?._count?.responses + 1).toString() : "1"}
min={
localSurvey?.analytics?.numResponses
? (localSurvey?.analytics?.numResponses + 1).toString()
: "1"
}
id="autoCompleteResponses"
value={localSurvey.autoComplete?.toString()}
onChange={handleInputResponse}

View File

@@ -1,35 +1,44 @@
import type { Survey } from "@formbricks/types/surveys";
import HowToSendCard from "./HowToSendCard";
import RecontactOptionsCard from "./RecontactOptionsCard";
import ResponseOptionsCard from "./ResponseOptionsCard";
import WhenToSendCard from "./WhenToSendCard";
import WhoToSendCard from "./WhoToSendCard";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TActionClass } from "@formbricks/types/v1/actionClasses";
import { TAttributeClass } from "@formbricks/types/v1/attributeClasses";
interface SettingsViewProps {
environmentId: string;
localSurvey: Survey;
setLocalSurvey: (survey: Survey) => void;
environment: TEnvironment;
localSurvey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
actionClasses: TActionClass[];
attributeClasses: TAttributeClass[];
}
export default function SettingsView({ environmentId, localSurvey, setLocalSurvey }: SettingsViewProps) {
export default function SettingsView({
environment,
localSurvey,
setLocalSurvey,
actionClasses,
attributeClasses,
}: SettingsViewProps) {
return (
<div className="mt-12 space-y-3 p-5">
<HowToSendCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
environmentId={environmentId}
/>
<HowToSendCard localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} environment={environment} />
<WhoToSendCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
environmentId={environmentId}
environmentId={environment.id}
attributeClasses={attributeClasses}
/>
<WhenToSendCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
environmentId={environmentId}
environmentId={environment.id}
actionClasses={actionClasses}
/>
<ResponseOptionsCard localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />
@@ -37,7 +46,7 @@ export default function SettingsView({ environmentId, localSurvey, setLocalSurve
<RecontactOptionsCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
environmentId={environmentId}
environmentId={environment.id}
/>
</div>
);

View File

@@ -1,10 +1,6 @@
"use client";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useProduct } from "@/lib/products/products";
import { useSurvey } from "@/lib/surveys/surveys";
import type { Survey } from "@formbricks/types/surveys";
import { ErrorComponent } from "@formbricks/ui";
import React from "react";
import { useEffect, useState } from "react";
import PreviewSurvey from "../../PreviewSurvey";
import QuestionsAudienceTabs from "./QuestionsSettingsTabs";
@@ -12,24 +8,31 @@ import QuestionsView from "./QuestionsView";
import SettingsView from "./SettingsView";
import SurveyMenuBar from "./SurveyMenuBar";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { TProduct } from "@formbricks/types/v1/product";
import { TAttributeClass } from "@formbricks/types/v1/attributeClasses";
import { TActionClass } from "@formbricks/types/v1/actionClasses";
import { ErrorComponent } from "@formbricks/ui";
interface SurveyEditorProps {
environmentId: string;
surveyId: string;
survey: TSurveyWithAnalytics;
product: TProduct;
environment: TEnvironment;
actionClasses: TActionClass[];
attributeClasses: TAttributeClass[];
}
export default function SurveyEditor({
environmentId,
surveyId,
survey,
product,
environment,
actionClasses,
attributeClasses,
}: SurveyEditorProps): JSX.Element {
const [activeView, setActiveView] = useState<"questions" | "settings">("questions");
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
const [localSurvey, setLocalSurvey] = useState<Survey | null>();
const [localSurvey, setLocalSurvey] = useState<TSurveyWithAnalytics | null>();
const [invalidQuestions, setInvalidQuestions] = useState<String[] | null>(null);
const { survey, isLoadingSurvey, isErrorSurvey } = useSurvey(environmentId, surveyId, true);
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
useEffect(() => {
if (survey) {
@@ -48,59 +51,58 @@ export default function SurveyEditor({
}
}, [localSurvey?.type]);
if (isLoadingSurvey || isLoadingProduct || !localSurvey) {
return <LoadingSpinner />;
}
if (isErrorSurvey || isErrorProduct) {
if (!localSurvey) {
return <ErrorComponent />;
}
return (
<div className="flex h-full flex-col">
<SurveyMenuBar
setLocalSurvey={setLocalSurvey}
localSurvey={localSurvey}
survey={survey}
environmentId={environmentId}
environment={environment}
activeId={activeView}
setActiveId={setActiveView}
setInvalidQuestions={setInvalidQuestions}
/>
<div className="relative z-0 flex flex-1 overflow-hidden">
<main className="relative z-0 flex-1 overflow-y-auto focus:outline-none">
<QuestionsAudienceTabs activeId={activeView} setActiveId={setActiveView} />
{activeView === "questions" ? (
<QuestionsView
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeQuestionId={activeQuestionId}
<>
<div className="flex h-full flex-col">
<SurveyMenuBar
setLocalSurvey={setLocalSurvey}
localSurvey={localSurvey}
survey={survey}
environment={environment}
activeId={activeView}
setActiveId={setActiveView}
setInvalidQuestions={setInvalidQuestions}
product={product}
/>
<div className="relative z-0 flex flex-1 overflow-hidden">
<main className="relative z-0 flex-1 overflow-y-auto focus:outline-none">
<QuestionsAudienceTabs activeId={activeView} setActiveId={setActiveView} />
{activeView === "questions" ? (
<QuestionsView
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
activeQuestionId={activeQuestionId}
setActiveQuestionId={setActiveQuestionId}
environmentId={environment.id}
invalidQuestions={invalidQuestions}
setInvalidQuestions={setInvalidQuestions}
/>
) : (
<SettingsView
environment={environment}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
actionClasses={actionClasses}
attributeClasses={attributeClasses}
/>
)}
</main>
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-100 bg-slate-50 py-6 md:flex md:flex-col">
<PreviewSurvey
survey={localSurvey}
setActiveQuestionId={setActiveQuestionId}
environmentId={environmentId}
invalidQuestions={invalidQuestions}
setInvalidQuestions={setInvalidQuestions}
activeQuestionId={activeQuestionId}
product={product}
environment={environment}
previewType={localSurvey.type === "web" ? "modal" : "fullwidth"}
/>
) : (
<SettingsView
environmentId={environmentId}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
/>
)}
</main>
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-100 bg-slate-50 py-6 md:flex md:flex-col">
<PreviewSurvey
survey={localSurvey}
activeQuestionId={activeQuestionId}
setActiveQuestionId={setActiveQuestionId}
environmentId={environmentId}
product={product}
environment={environment}
previewType={localSurvey.type === "web" ? "modal" : "fullwidth"}
/>
</aside>
</aside>
</div>
</div>
</div>
</>
);
}

View File

@@ -1,11 +1,9 @@
"use client";
import React from "react";
import AlertDialog from "@/components/shared/AlertDialog";
import DeleteDialog from "@/components/shared/DeleteDialog";
import SurveyStatusDropdown from "@/components/shared/SurveyStatusDropdown";
import { useProduct } from "@/lib/products/products";
import { useSurveyMutation } from "@/lib/surveys/mutateSurveys";
import { deleteSurvey } from "@/lib/surveys/surveys";
import type { Survey } from "@formbricks/types/surveys";
import { Button, Input } from "@formbricks/ui";
import { ArrowLeftIcon, Cog8ToothIcon, ExclamationTriangleIcon } from "@heroicons/react/24/solid";
@@ -14,35 +12,37 @@ import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { validateQuestion } from "./Validation";
import { TSurvey, TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { deleteSurveyAction, surveyMutateAction } from "./actions";
import { TProduct } from "@formbricks/types/v1/product";
import { TEnvironment } from "@formbricks/types/v1/environment";
interface SurveyMenuBarProps {
localSurvey: Survey;
survey: Survey;
setLocalSurvey: (survey: Survey) => void;
environmentId: string;
localSurvey: TSurveyWithAnalytics;
survey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
environment: TEnvironment;
activeId: "questions" | "settings";
setActiveId: (id: "questions" | "settings") => void;
setInvalidQuestions: (invalidQuestions: String[]) => void;
product: TProduct;
}
export default function SurveyMenuBar({
localSurvey,
survey,
environmentId,
environment,
setLocalSurvey,
activeId,
setActiveId,
setInvalidQuestions,
product,
}: SurveyMenuBarProps) {
const router = useRouter();
const { triggerSurveyMutate, isMutatingSurvey } = useSurveyMutation(environmentId, localSurvey.id);
const [audiencePrompt, setAudiencePrompt] = useState(true);
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isConfirmDialogOpen, setConfirmDialogOpen] = useState(false);
const { product } = useProduct(environmentId);
const [isMutatingSurvey, setIsMutatingSurvey] = useState(false);
let faultyQuestions: String[] = [];
useEffect(() => {
@@ -73,9 +73,10 @@ export default function SurveyMenuBar({
setLocalSurvey(updatedSurvey);
};
const deleteSurveyAction = async (survey) => {
const deleteSurvey = async (surveyId) => {
try {
await deleteSurvey(environmentId, survey.id);
await deleteSurveyAction(surveyId);
router.refresh();
setDeleteDialogOpen(false);
router.back();
} catch (error) {
@@ -84,7 +85,10 @@ export default function SurveyMenuBar({
};
const handleBack = () => {
if (localSurvey.createdAt === localSurvey.updatedAt && localSurvey.status === "draft") {
const createdAt = new Date(localSurvey.createdAt).getTime();
const updatedAt = new Date(localSurvey.updatedAt).getTime();
if (createdAt === updatedAt && localSurvey.status === "draft") {
setDeleteDialogOpen(true);
} else if (!isEqual(localSurvey, survey)) {
setConfirmDialogOpen(true);
@@ -121,21 +125,13 @@ export default function SurveyMenuBar({
return false;
}
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 = (shouldNavigateBack = false) => {
// variable named strippedSurvey that is a copy of localSurvey with isDraft removed from every question
const strippedSurvey = {
const saveSurveyAction = async (shouldNavigateBack = false) => {
setIsMutatingSurvey(true);
// Create a copy of localSurvey with isDraft removed from every question
const strippedSurvey: TSurvey = {
...localSurvey,
questions: localSurvey.questions.map((question) => {
const { isDraft, ...rest } = question;
@@ -147,28 +143,26 @@ export default function SurveyMenuBar({
return;
}
triggerSurveyMutate({ ...strippedSurvey })
.then(async (response) => {
if (!response?.ok) {
throw new Error(await response?.text());
}
const updatedSurvey = await response.json();
setLocalSurvey(updatedSurvey);
toast.success("Changes saved.");
if (shouldNavigateBack) {
router.back();
try {
await surveyMutateAction({ ...strippedSurvey });
router.refresh();
setIsMutatingSurvey(false);
toast.success("Changes saved.");
if (shouldNavigateBack) {
router.back();
} else {
if (localSurvey.status !== "draft") {
router.push(`/environments/${environment.id}/surveys/${localSurvey.id}/summary`);
} else {
if (localSurvey.status !== "draft") {
router.push(`/environments/${environmentId}/surveys/${localSurvey.id}/summary`);
} else {
router.push(`/environments/${environmentId}/surveys`);
}
router.push(`/environments/${environment.id}/surveys`);
}
})
.catch((error) => {
console.log(error);
toast.error(`Error saving changes`);
});
}
} catch (e) {
console.error(e);
setIsMutatingSurvey(false);
toast.error(`Error saving changes`);
return;
}
};
return (
@@ -200,7 +194,7 @@ export default function SurveyMenuBar({
className="w-72 border-white hover:border-slate-200 "
/>
</div>
{!!localSurvey?.responseRate && (
{!!localSurvey.analytics.responseRate && (
<div className="mx-auto flex items-center rounded-full border border-amber-200 bg-amber-100 p-2 text-amber-700 shadow-sm">
<ExclamationTriangleIcon className=" h-5 w-5 text-amber-400" />
<p className="max-w-[90%] pl-1 text-xs lg:text-sm">
@@ -212,7 +206,7 @@ export default function SurveyMenuBar({
<div className="mr-4 flex items-center">
<SurveyStatusDropdown
surveyId={localSurvey.id}
environmentId={environmentId}
environmentId={environment.id}
updateLocalSurveyStatus={updateLocalSurveyStatus}
/>
</div>
@@ -239,16 +233,19 @@ export default function SurveyMenuBar({
disabled={
localSurvey.type === "web" &&
localSurvey.triggers &&
(localSurvey.triggers[0] === "" || localSurvey.triggers.length === 0)
(localSurvey.triggers[0]?.id === "" || localSurvey.triggers.length === 0)
}
variant="darkCTA"
loading={isMutatingSurvey}
onClick={async () => {
setIsMutatingSurvey(true);
if (!validateSurvey(localSurvey)) {
return;
}
await triggerSurveyMutate({ ...localSurvey, status: "inProgress" });
router.push(`/environments/${environmentId}/surveys/${localSurvey.id}/summary?success=true`);
await surveyMutateAction({ ...localSurvey, status: "inProgress" });
router.refresh();
setIsMutatingSurvey(false);
router.push(`/environments/${environment.id}/surveys/${localSurvey.id}/summary?success=true`);
}}>
Publish
</Button>
@@ -258,7 +255,7 @@ export default function SurveyMenuBar({
deleteWhat="Draft"
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
onDelete={() => deleteSurveyAction(localSurvey)}
onDelete={() => deleteSurvey(localSurvey.id)}
text="Do you want to delete this draft?"
useSaveInsteadOfCancel={true}
onSave={() => saveSurveyAction(true)}

View File

@@ -1,19 +1,19 @@
// extend this object in order to add more validation rules
import {
MultipleChoiceMultiQuestion,
MultipleChoiceSingleQuestion,
Question,
} from "@formbricks/types/questions";
TSurveyMultipleChoiceMultiQuestion,
TSurveyMultipleChoiceSingleQuestion,
TSurveyQuestion,
} from "@formbricks/types/v1/surveys";
const validationRules = {
multipleChoiceMulti: (question: MultipleChoiceMultiQuestion) => {
multipleChoiceMulti: (question: TSurveyMultipleChoiceMultiQuestion) => {
return !question.choices.some((element) => element.label.trim() === "");
},
multipleChoiceSingle: (question: MultipleChoiceSingleQuestion) => {
multipleChoiceSingle: (question: TSurveyMultipleChoiceSingleQuestion) => {
return !question.choices.some((element) => element.label.trim() === "");
},
defaultValidation: (question: Question) => {
defaultValidation: (question: TSurveyQuestion) => {
return question.headline.trim() !== "";
},
};

View File

@@ -1,10 +1,7 @@
"use client";
import AddNoCodeActionModal from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/AddNoCodeActionModal";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useEventClasses } from "@/lib/eventClasses/eventClasses";
import { cn } from "@formbricks/lib/cn";
import type { Survey } from "@formbricks/types/surveys";
import {
AdvancedOptionToggle,
Badge,
@@ -20,29 +17,51 @@ import {
import { CheckCircleIcon, PlusIcon, TrashIcon } from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useEffect, useState } from "react";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { TActionClass } from "@formbricks/types/v1/actionClasses";
interface WhenToSendCardProps {
localSurvey: Survey;
setLocalSurvey: (survey: Survey) => void;
localSurvey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
environmentId: string;
actionClasses: TActionClass[];
}
export default function WhenToSendCard({ environmentId, localSurvey, setLocalSurvey }: WhenToSendCardProps) {
export default function WhenToSendCard({
environmentId,
localSurvey,
setLocalSurvey,
actionClasses,
}: WhenToSendCardProps) {
const [open, setOpen] = useState(localSurvey.type === "web" ? true : false);
const { eventClasses, isLoadingEventClasses, isErrorEventClasses } = useEventClasses(environmentId);
const [isAddEventModalOpen, setAddEventModalOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [actionClassArray, setActionClassArray] = useState<TActionClass[]>(actionClasses);
const autoClose = localSurvey.autoClose !== null;
let newTrigger = {
id: "", // Set the appropriate value for the id
createdAt: new Date(),
updatedAt: new Date(),
name: "",
type: "code" as const, // Set the appropriate value for the type
environmentId: "",
description: null,
noCodeConfig: null,
};
const addTriggerEvent = () => {
const updatedSurvey = { ...localSurvey };
updatedSurvey.triggers = [...localSurvey.triggers, ""];
updatedSurvey.triggers = [...localSurvey.triggers, newTrigger];
setLocalSurvey(updatedSurvey);
};
const setTriggerEvent = (idx: number, eventClassId: string) => {
const setTriggerEvent = (idx: number, actionClassId: string) => {
const updatedSurvey = { ...localSurvey };
updatedSurvey.triggers[idx] = eventClassId;
updatedSurvey.triggers[idx] = actionClassArray!.find((actionClass) => {
return actionClass.id === actionClassId;
})!;
setLocalSurvey(updatedSurvey);
};
@@ -54,10 +73,10 @@ export default function WhenToSendCard({ environmentId, localSurvey, setLocalSur
const handleCheckMark = () => {
if (autoClose) {
const updatedSurvey: Survey = { ...localSurvey, autoClose: null };
const updatedSurvey: TSurveyWithAnalytics = { ...localSurvey, autoClose: null };
setLocalSurvey(updatedSurvey);
} else {
const updatedSurvey: Survey = { ...localSurvey, autoClose: 10 };
const updatedSurvey: TSurveyWithAnalytics = { ...localSurvey, autoClose: 10 };
setLocalSurvey(updatedSurvey);
}
};
@@ -67,15 +86,21 @@ export default function WhenToSendCard({ environmentId, localSurvey, setLocalSur
if (value < 1) value = 1;
const updatedSurvey: Survey = { ...localSurvey, autoClose: value };
const updatedSurvey: TSurveyWithAnalytics = { ...localSurvey, autoClose: value };
setLocalSurvey(updatedSurvey);
};
const handleTriggerDelay = (e: any) => {
let value = parseInt(e.target.value);
const updatedSurvey: Survey = { ...localSurvey, delay: value };
const updatedSurvey: TSurveyWithAnalytics = { ...localSurvey, delay: value };
setLocalSurvey(updatedSurvey);
};
useEffect(() => {
console.log(actionClassArray);
if (activeIndex !== null) {
setTriggerEvent(activeIndex, actionClassArray[actionClassArray.length - 1].id);
}
}, [actionClassArray]);
useEffect(() => {
if (localSurvey.type === "link") {
@@ -90,14 +115,6 @@ export default function WhenToSendCard({ environmentId, localSurvey, setLocalSur
}
}, []);
if (isLoadingEventClasses) {
return <LoadingSpinner />;
}
if (isErrorEventClasses) {
return <div>Error</div>;
}
return (
<>
<Collapsible.Root
@@ -118,9 +135,7 @@ export default function WhenToSendCard({ environmentId, localSurvey, setLocalSur
)}>
<div className="inline-flex px-4 py-4">
<div className="flex items-center pl-2 pr-5">
{!localSurvey.triggers ||
localSurvey.triggers.length === 0 ||
localSurvey.triggers[0] === "" ? (
{!localSurvey.triggers || localSurvey.triggers.length === 0 || !localSurvey.triggers[0]?.id ? (
<div
className={cn(
localSurvey.type !== "link"
@@ -152,13 +167,13 @@ export default function WhenToSendCard({ environmentId, localSurvey, setLocalSur
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className="">
<hr className="py-1 text-slate-600" />
{localSurvey.triggers?.map((triggerEventClassId, idx) => (
{localSurvey.triggers?.map((triggerEventClass, idx) => (
<div className="mt-2" key={idx}>
<div className="inline-flex items-center">
<p className="mr-2 w-14 text-right text-sm">{idx === 0 ? "When" : "or"}</p>
<Select
value={triggerEventClassId}
onValueChange={(eventClassId) => setTriggerEvent(idx, eventClassId)}>
value={triggerEventClass.id}
onValueChange={(actionClassId) => setTriggerEvent(idx, actionClassId)}>
<SelectTrigger className="w-[240px]">
<SelectValue />
</SelectTrigger>
@@ -168,14 +183,18 @@ export default function WhenToSendCard({ environmentId, localSurvey, setLocalSur
value="none"
onClick={() => {
setAddEventModalOpen(true);
setActiveIndex(idx);
}}>
<PlusIcon className="mr-1 h-5 w-5" />
Add Action
</button>
<SelectSeparator />
{eventClasses.map((eventClass) => (
<SelectItem value={eventClass.id} key={eventClass.id} title={eventClass.description}>
{eventClass.name}
{actionClassArray.map((actionClass) => (
<SelectItem
value={actionClass.id}
key={actionClass.id}
title={actionClass.description ? actionClass.description : ""}>
{actionClass.name}
</SelectItem>
))}
</SelectContent>
@@ -249,6 +268,7 @@ export default function WhenToSendCard({ environmentId, localSurvey, setLocalSur
environmentId={environmentId}
open={isAddEventModalOpen}
setOpen={setAddEventModalOpen}
setActionClassArray={setActionClassArray}
/>
</>
);

View File

@@ -1,9 +1,6 @@
"use client";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useAttributeClasses } from "@/lib/attributeClasses/attributeClasses";
import { cn } from "@formbricks/lib/cn";
import type { Survey } from "@formbricks/types/surveys";
import {
Badge,
Button,
@@ -17,6 +14,8 @@ import {
import { CheckCircleIcon, FunnelIcon, PlusIcon, TrashIcon, UserGroupIcon } from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useEffect, useState } from "react"; /* */
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { TAttributeClass } from "@formbricks/types/v1/attributeClasses";
const filterConditions = [
{ id: "equals", name: "equals" },
@@ -24,23 +23,15 @@ const filterConditions = [
];
interface WhoToSendCardProps {
localSurvey: Survey;
setLocalSurvey: (survey: Survey) => void;
localSurvey: TSurveyWithAnalytics;
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
environmentId: string;
attributeClasses: TAttributeClass[];
}
export default function WhoToSendCard({ environmentId, localSurvey, setLocalSurvey }: WhoToSendCardProps) {
export default function WhoToSendCard({ localSurvey, setLocalSurvey, attributeClasses }: WhoToSendCardProps) {
const [open, setOpen] = useState(false);
const { attributeClasses, isLoadingAttributeClasses, isErrorAttributeClasses } =
useAttributeClasses(environmentId);
useEffect(() => {
if (!isLoadingAttributeClasses) {
if (localSurvey.attributeFilters?.length > 0) {
setOpen(true);
}
}
}, [isLoadingAttributeClasses]);
const condition = filterConditions[0].id === "equals" ? "equals" : "notEquals";
useEffect(() => {
if (localSurvey.type === "link") {
@@ -52,14 +43,18 @@ export default function WhoToSendCard({ environmentId, localSurvey, setLocalSurv
const updatedSurvey = { ...localSurvey };
updatedSurvey.attributeFilters = [
...localSurvey.attributeFilters,
{ attributeClassId: "", condition: filterConditions[0].id, value: "" },
{ attributeClassId: "", condition: condition, value: "" },
];
setLocalSurvey(updatedSurvey);
};
const setAttributeFilter = (idx: number, attributeClassId: string, condition: string, value: string) => {
const updatedSurvey = { ...localSurvey };
updatedSurvey.attributeFilters[idx] = { attributeClassId, condition, value };
updatedSurvey.attributeFilters[idx] = {
attributeClassId,
condition: condition === "equals" ? "equals" : "notEquals",
value,
};
setLocalSurvey(updatedSurvey);
};
@@ -72,14 +67,6 @@ export default function WhoToSendCard({ environmentId, localSurvey, setLocalSurv
setLocalSurvey(updatedSurvey);
};
if (isLoadingAttributeClasses) {
return <LoadingSpinner />;
}
if (isErrorAttributeClasses) {
return <div>Error</div>;
}
return (
<>
<Collapsible.Root
@@ -187,14 +174,15 @@ export default function WhoToSendCard({ environmentId, localSurvey, setLocalSurv
</Select>
<Input
value={attributeFilter.value}
onChange={(e) =>
onChange={(e) => {
e.preventDefault();
setAttributeFilter(
idx,
attributeFilter.attributeClassId,
attributeFilter.condition,
e.target.value
)
}
);
}}
/>
<button onClick={() => removeAttributeFilter(idx)}>
<TrashIcon className="h-4 w-4 text-slate-400" />

View File

@@ -0,0 +1,12 @@
"use server";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { deleteSurvey, updateSurvey } from "@formbricks/lib/services/survey";
export async function surveyMutateAction(survey: TSurvey): Promise<TSurvey> {
return await updateSurvey(survey);
}
export async function deleteSurveyAction(surveyId: string) {
await deleteSurvey(surveyId);
}

View File

@@ -0,0 +1,27 @@
export default function Loading() {
return (
<div className="flex h-full w-full flex-col items-center justify-between p-6">
{/* Top Part - Loading Navbar */}
<div className="flex h-[10vh] w-full animate-pulse rounded-lg bg-gray-200 font-medium text-slate-900"></div>
{/* Bottom Part - Divided into Left and Right */}
<div className="mt-4 flex h-[85%] w-full flex-row">
{/* Left Part - 7 Horizontal Bars */}
<div className="flex h-full w-1/2 flex-col justify-between space-y-2">
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-gray-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-gray-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-gray-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-gray-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-gray-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-gray-200"></div>
<div className="ph-no-capture h-[10vh] animate-pulse rounded-lg bg-gray-200"></div>
</div>
{/* Right Part - Simple Box */}
<div className="ml-4 flex h-full w-1/2 flex-col">
<div className="ph-no-capture h-full animate-pulse rounded-lg bg-gray-200"></div>
</div>
</div>
</div>
);
}

View File

@@ -1,9 +1,35 @@
export const revalidate = REVALIDATION_INTERVAL;
import React from "react";
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import SurveyEditor from "./SurveyEditor";
import { getSurveyWithAnalytics } from "@formbricks/lib/services/survey";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { getEnvironment } from "@formbricks/lib/services/environment";
import { getActionClasses } from "@formbricks/lib/services/actionClass";
import { getAttributeClasses } from "@formbricks/lib/services/attributeClass";
import { ErrorComponent } from "@formbricks/ui";
export default async function SurveysEditPage({ params }) {
const environment = await getEnvironment(params.environmentId);
const [survey, product, environment, actionClasses, attributeClasses] = await Promise.all([
getSurveyWithAnalytics(params.surveyId),
getProductByEnvironmentId(params.environmentId),
getEnvironment(params.environmentId),
getActionClasses(params.environmentId),
getAttributeClasses(params.environmentId),
]);
if (!survey || !environment || !actionClasses || !attributeClasses || !product) {
return <ErrorComponent />;
}
return (
<SurveyEditor environmentId={params.environmentId} surveyId={params.surveyId} environment={environment} />
<>
<SurveyEditor
survey={survey}
product={product}
environment={environment}
actionClasses={actionClasses}
attributeClasses={attributeClasses}
/>
</>
);
}

View File

@@ -73,7 +73,6 @@ export default function TemplateContainerWithPreview({
product={product}
environment={environment}
setActiveQuestionId={setActiveQuestionId}
environmentId={environmentId}
/>
</div>
)}

View File

@@ -2059,5 +2059,7 @@ export const minimalSurvey: TSurvey = {
delay: 0, // No delay
autoComplete: null,
closeOnDate: null,
surveyClosedMessage: {},
surveyClosedMessage: {
enabled: false,
},
};

View File

@@ -21,7 +21,12 @@ export default async function LinkSurveyPage({ params, searchParams }) {
const isPrefilledAnswerValid = prefillAnswer ? checkValidity(survey!.questions[0], prefillAnswer) : false;
if (survey && survey.status !== "inProgress") {
return <SurveyInactive status={survey.status} surveyClosedMessage={survey.surveyClosedMessage} />;
return (
<SurveyInactive
status={survey.status}
surveyClosedMessage={survey.surveyClosedMessage ? survey.surveyClosedMessage : undefined}
/>
);
}
// verify email: Check if the survey requires email verification

View File

@@ -1,6 +1,6 @@
import { ReactNode } from "react";
export default function OptionButton({
export default function TabOption({
active,
icon,
onClick,

View File

@@ -7,7 +7,6 @@ import { validateInputs } from "../utils/validate";
import { ZId } from "@formbricks/types/v1/environment";
import { cache } from "react";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
const select = {
id: true,
createdAt: true,

View File

@@ -43,7 +43,6 @@ export const getAttributeClasses = cache(async (environmentId: string): Promise<
throw new DatabaseError(`Database error when fetching attributeClasses for environment ${environmentId}`);
}
});
export const updatetAttributeClass = async (
attributeClassId: string,
data: Partial<TAttributeClassUpdateInput>

View File

@@ -2,6 +2,7 @@ import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/v1/errors";
import { TSurvey, TSurveyWithAnalytics, ZSurvey, ZSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { Prisma } from "@prisma/client";
import { TSurveyAttributeFilter } from "@formbricks/types/v1/surveys";
import { cache } from "react";
import "server-only";
import { z } from "zod";
@@ -252,6 +253,187 @@ export const getSurveysWithAnalytics = cache(
}
);
export async function updateSurvey(updatedSurvey: TSurvey): Promise<TSurvey> {
const surveyId = updatedSurvey.id;
let data: any = {};
let survey: Partial<any> = { ...updatedSurvey };
if (updatedSurvey.triggers && updatedSurvey.triggers.length > 0) {
const modifiedTriggers = updatedSurvey.triggers.map((trigger) => {
if (typeof trigger === "object" && trigger.id) {
return trigger.id;
} else if (typeof trigger === "string" && trigger !== undefined) {
return trigger;
}
});
survey = { ...updatedSurvey, triggers: modifiedTriggers };
}
const currentTriggers = await prisma.surveyTrigger.findMany({
where: {
surveyId,
},
});
const currentAttributeFilters = await prisma.surveyAttributeFilter.findMany({
where: {
surveyId,
},
});
delete survey.updatedAt;
// preventing issue with unknowingly updating analytics
delete survey.analytics;
if (survey.type === "link") {
delete survey.triggers;
delete survey.recontactDays;
// converts JSON field with null value to JsonNull as JSON fields can't be set to null since prisma 3.0
if (!survey.surveyClosedMessage) {
survey.surveyClosedMessage = null;
}
}
if (survey.triggers) {
const newTriggers: string[] = [];
const removedTriggers: string[] = [];
// find added triggers
for (const eventClassId of survey.triggers) {
if (!eventClassId) {
continue;
}
if (currentTriggers.find((t) => t.eventClassId === eventClassId)) {
continue;
} else {
newTriggers.push(eventClassId);
}
}
// find removed triggers
for (const trigger of currentTriggers) {
if (survey.triggers.find((t: any) => t === trigger.eventClassId)) {
continue;
} else {
removedTriggers.push(trigger.eventClassId);
}
}
// create new triggers
if (newTriggers.length > 0) {
data.triggers = {
...(data.triggers || []),
create: newTriggers.map((eventClassId) => ({
eventClassId,
})),
};
}
// delete removed triggers
if (removedTriggers.length > 0) {
data.triggers = {
...(data.triggers || []),
deleteMany: {
eventClassId: {
in: removedTriggers,
},
},
};
}
delete survey.triggers;
}
const attributeFilters: TSurveyAttributeFilter[] = survey.attributeFilters;
if (attributeFilters) {
const newFilters: TSurveyAttributeFilter[] = [];
const removedFilterIds: string[] = [];
// find added attribute filters
for (const attributeFilter of attributeFilters) {
if (!attributeFilter.attributeClassId || !attributeFilter.condition || !attributeFilter.value) {
continue;
}
if (
currentAttributeFilters.find(
(f) =>
f.attributeClassId === attributeFilter.attributeClassId &&
f.condition === attributeFilter.condition &&
f.value === attributeFilter.value
)
) {
continue;
} else {
newFilters.push({
attributeClassId: attributeFilter.attributeClassId,
condition: attributeFilter.condition,
value: attributeFilter.value,
});
}
}
// find removed attribute filters
for (const attributeFilter of currentAttributeFilters) {
if (
attributeFilters.find(
(f) =>
f.attributeClassId === attributeFilter.attributeClassId &&
f.condition === attributeFilter.condition &&
f.value === attributeFilter.value
)
) {
continue;
} else {
removedFilterIds.push(attributeFilter.attributeClassId);
}
}
// create new attribute filters
if (newFilters.length > 0) {
data.attributeFilters = {
...(data.attributeFilters || []),
create: newFilters.map((attributeFilter) => ({
attributeClassId: attributeFilter.attributeClassId,
condition: attributeFilter.condition,
value: attributeFilter.value,
})),
};
}
// delete removed triggers
if (removedFilterIds.length > 0) {
// delete all attribute filters that match the removed attribute classes
await Promise.all(
removedFilterIds.map(async (attributeClassId) => {
await prisma.surveyAttributeFilter.deleteMany({
where: {
attributeClassId,
},
});
})
);
}
delete survey.attributeFilters;
}
data = {
...data,
...survey,
};
try {
const prismaSurvey = await prisma.survey.update({
where: { id: surveyId },
data,
});
const modifiedSurvey: TSurvey = {
...prismaSurvey, // Properties from prismaSurvey
triggers: updatedSurvey.triggers, // Include triggers from updatedSurvey
attributeFilters: updatedSurvey.attributeFilters, // Include attributeFilters from updatedSurvey
};
return modifiedSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
}
export async function deleteSurvey(surveyId: string) {
validateInputs([surveyId, ZId]);
const deletedSurvey = await prisma.survey.delete({

View File

@@ -10,9 +10,11 @@ export const ZSurveyThankYouCard = z.object({
export const ZSurveyClosedMessage = z
.object({
enabled: z.boolean(),
heading: z.optional(z.string()),
subheading: z.optional(z.string()),
})
.nullable()
.optional();
export const ZSurveyVerifyEmail = z
@@ -135,6 +137,7 @@ const ZSurveyQuestionBase = z.object({
scale: z.enum(["number", "smiley", "star"]).optional(),
range: z.union([z.literal(5), z.literal(3), z.literal(4), z.literal(7), z.literal(10)]).optional(),
logic: z.array(ZSurveyLogic).optional(),
isDraft: z.boolean().optional(),
});
export const ZSurveyOpenTextQuestion = ZSurveyQuestionBase.extend({
@@ -223,12 +226,13 @@ export const ZSurveyQuestions = z.array(ZSurveyQuestion);
export type TSurveyQuestions = z.infer<typeof ZSurveyQuestions>;
export const ZSurveyAttributeFilter = z.object({
id: z.string().cuid2(),
attributeClassId: z.string(),
condition: z.enum(["equals", "notEquals"]),
value: z.string(),
});
export type TSurveyAttributeFilter = z.infer<typeof ZSurveyAttributeFilter>;
export const ZSurvey = z.object({
id: z.string().cuid2(),
createdAt: z.date(),