Refactor Survey Overview Page

Refactor Survey Overview Page
This commit is contained in:
Johannes
2023-07-20 06:35:36 -05:00
committed by GitHub
16 changed files with 751 additions and 313 deletions

View File

@@ -1,9 +1,12 @@
"use server";
import { prisma } from "@formbricks/database";
import { Team } from "@prisma/client";
import { ResourceNotFoundError } from "@formbricks/errors";
import { deleteSurvey, getSurvey } from "@formbricks/lib/services/survey";
import { QuestionType } from "@formbricks/types/questions";
import { createId } from "@paralleldrive/cuid2";
import { Team } from "@prisma/client";
import { Prisma as prismaClient } from "@prisma/client/";
export async function createTeam(teamName: string, ownerUserId: string): Promise<Team> {
const newTeam = await prisma.team.create({
@@ -1372,3 +1375,174 @@ export async function addDemoData(teamId: string): Promise<void> {
InterviewPromptResults.displays
);
}
export async function duplicateSurveyAction(environmentId: string, surveyId: string) {
const existingSurvey = await getSurvey(surveyId);
if (!existingSurvey) {
throw new ResourceNotFoundError("Survey", surveyId);
}
// create new survey with the data of the existing survey
const newSurvey = await prisma.survey.create({
data: {
...existingSurvey,
id: undefined, // id is auto-generated
environmentId: undefined, // environmentId is set below
name: `${existingSurvey.name} (copy)`,
status: "draft",
questions: JSON.parse(JSON.stringify(existingSurvey.questions)),
thankYouCard: JSON.parse(JSON.stringify(existingSurvey.thankYouCard)),
triggers: {
create: existingSurvey.triggers.map((trigger) => ({
eventClassId: trigger.id,
})),
},
attributeFilters: {
create: existingSurvey.attributeFilters.map((attributeFilter) => ({
attributeClassId: attributeFilter.attributeClassId,
condition: attributeFilter.condition,
value: attributeFilter.value,
})),
},
environment: {
connect: {
id: environmentId,
},
},
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
},
});
return newSurvey;
}
export async function copyToOtherEnvironmentAction(
environmentId: string,
surveyId: string,
targetEnvironmentId: string
) {
const existingSurvey = await prisma.survey.findFirst({
where: {
id: surveyId,
environmentId,
},
include: {
triggers: {
include: {
eventClass: true,
},
},
attributeFilters: {
include: {
attributeClass: true,
},
},
},
});
if (!existingSurvey) {
throw new ResourceNotFoundError("Survey", surveyId);
}
let targetEnvironmentTriggers: string[] = [];
// map the local triggers to the target environment
for (const trigger of existingSurvey.triggers) {
const targetEnvironmentTrigger = await prisma.eventClass.findFirst({
where: {
name: trigger.eventClass.name,
environment: {
id: targetEnvironmentId,
},
},
});
if (!targetEnvironmentTrigger) {
// if the trigger does not exist in the target environment, create it
const newTrigger = await prisma.eventClass.create({
data: {
name: trigger.eventClass.name,
environment: {
connect: {
id: targetEnvironmentId,
},
},
description: trigger.eventClass.description,
type: trigger.eventClass.type,
noCodeConfig: trigger.eventClass.noCodeConfig
? JSON.parse(JSON.stringify(trigger.eventClass.noCodeConfig))
: undefined,
},
});
targetEnvironmentTriggers.push(newTrigger.id);
} else {
targetEnvironmentTriggers.push(targetEnvironmentTrigger.id);
}
}
let targetEnvironmentAttributeFilters: string[] = [];
// map the local attributeFilters to the target env
for (const attributeFilter of existingSurvey.attributeFilters) {
// check if attributeClass exists in target env.
// if not, create it
const targetEnvironmentAttributeClass = await prisma.attributeClass.findFirst({
where: {
name: attributeFilter.attributeClass.name,
environment: {
id: targetEnvironmentId,
},
},
});
if (!targetEnvironmentAttributeClass) {
const newAttributeClass = await prisma.attributeClass.create({
data: {
name: attributeFilter.attributeClass.name,
description: attributeFilter.attributeClass.description,
type: attributeFilter.attributeClass.type,
environment: {
connect: {
id: targetEnvironmentId,
},
},
},
});
targetEnvironmentAttributeFilters.push(newAttributeClass.id);
} else {
targetEnvironmentAttributeFilters.push(targetEnvironmentAttributeClass.id);
}
}
// create new survey with the data of the existing survey
const newSurvey = await prisma.survey.create({
data: {
...existingSurvey,
id: undefined, // id is auto-generated
environmentId: undefined, // environmentId is set below
name: `${existingSurvey.name} (copy)`,
status: "draft",
questions: JSON.parse(JSON.stringify(existingSurvey.questions)),
thankYouCard: JSON.parse(JSON.stringify(existingSurvey.thankYouCard)),
triggers: {
create: targetEnvironmentTriggers.map((eventClassId) => ({
eventClassId: eventClassId,
})),
},
attributeFilters: {
create: existingSurvey.attributeFilters.map((attributeFilter, idx) => ({
attributeClassId: targetEnvironmentAttributeFilters[idx],
condition: attributeFilter.condition,
value: attributeFilter.value,
})),
},
environment: {
connect: {
id: targetEnvironmentId,
},
},
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
},
});
return newSurvey;
}
export const deleteSurveyAction = async (surveyId: string) => {
await deleteSurvey(surveyId);
};

View File

@@ -0,0 +1,198 @@
"use client";
import {
copyToOtherEnvironmentAction,
deleteSurveyAction,
duplicateSurveyAction,
} from "@/app/(app)/environments/[environmentId]/actions";
import DeleteDialog from "@/components/shared/DeleteDialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/shared/DropdownMenu";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import type { TEnvironment } from "@formbricks/types/v1/environment";
import type { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import {
ArrowUpOnSquareStackIcon,
DocumentDuplicateIcon,
EllipsisHorizontalIcon,
EyeIcon,
LinkIcon,
PencilSquareIcon,
TrashIcon,
} from "@heroicons/react/24/solid";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
interface SurveyDropDownMenuProps {
environmentId: string;
survey: TSurveyWithAnalytics;
environment: TEnvironment;
otherEnvironment: TEnvironment;
}
export default function SurveyDropDownMenu({
environmentId,
survey,
environment,
otherEnvironment,
}: SurveyDropDownMenuProps) {
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleDeleteSurvey = async (survey) => {
setLoading(true);
try {
await deleteSurveyAction(survey.id);
router.refresh();
setDeleteDialogOpen(false);
toast.success("Survey deleted successfully.");
} catch (error) {
toast.error("An error occured while deleting survey");
}
setLoading(false);
};
const duplicateSurveyAndRefresh = async (surveyId) => {
setLoading(true);
try {
await duplicateSurveyAction(environmentId, surveyId);
router.refresh();
toast.success("Survey duplicated successfully.");
} catch (error) {
toast.error("Failed to duplicate the survey.");
}
setLoading(false);
};
const copyToOtherEnvironment = async (surveyId) => {
setLoading(true);
try {
await copyToOtherEnvironmentAction(environmentId, surveyId, otherEnvironment.id);
if (otherEnvironment.type === "production") {
toast.success("Survey copied to production env.");
} else if (otherEnvironment.type === "development") {
toast.success("Survey copied to development env.");
}
router.replace(`/environments/${otherEnvironment.id}`);
} catch (error) {
toast.error(`Failed to copy to ${otherEnvironment.type}`);
}
setLoading(false);
};
if (loading) {
return (
<div className="opacity-0.2 absolute left-0 top-0 h-full w-full bg-gray-100">
<LoadingSpinner />
</div>
);
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
<div>
<span className="sr-only">Open options</span>
<EllipsisHorizontalIcon className="h-5 w-5" aria-hidden="true" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-40">
<DropdownMenuGroup>
<DropdownMenuItem>
<Link
className="flex w-full items-center"
href={`/environments/${environmentId}/surveys/${survey.id}/edit`}>
<PencilSquareIcon className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<button
className="flex w-full items-center"
onClick={async () => {
duplicateSurveyAndRefresh(survey.id);
}}>
<DocumentDuplicateIcon className="mr-2 h-4 w-4" />
Duplicate
</button>
</DropdownMenuItem>
{environment.type === "development" ? (
<DropdownMenuItem>
<button
className="flex w-full items-center"
onClick={() => {
copyToOtherEnvironment(survey.id);
}}>
<ArrowUpOnSquareStackIcon className="mr-2 h-4 w-4" />
Copy to Prod
</button>
</DropdownMenuItem>
) : environment.type === "production" ? (
<DropdownMenuItem>
<button
className="flex w-full items-center"
onClick={() => {
copyToOtherEnvironment(survey.id);
}}>
<ArrowUpOnSquareStackIcon className="mr-2 h-4 w-4" />
Copy to Dev
</button>
</DropdownMenuItem>
) : null}
{survey.type === "link" && survey.status !== "draft" && (
<>
<DropdownMenuItem>
<Link
className="flex w-full items-center"
href={`/s/${survey.id}?preview=true`}
target="_blank">
<EyeIcon className="mr-2 h-4 w-4" />
Preview Survey
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<button
className="flex w-full items-center"
onClick={() => {
navigator.clipboard.writeText(
`${window.location.protocol}//${window.location.host}/s/${survey.id}`
);
toast.success("Copied link to clipboard");
}}>
<LinkIcon className="mr-2 h-4 w-4" />
Copy Link
</button>
</DropdownMenuItem>
</>
)}
<DropdownMenuItem>
<button
className="flex w-full items-center"
onClick={() => {
setDeleteDialogOpen(true);
}}>
<TrashIcon className="mr-2 h-4 w-4" />
Delete
</button>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DeleteDialog
deleteWhat="Survey"
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
onDelete={() => handleDeleteSurvey(survey)}
text="Are you sure you want to delete this survey and all of its responses? This action cannot be undone."
/>
</>
);
}

View File

@@ -1,156 +1,30 @@
"use client";
import { Template } from "@/../../packages/types/templates";
import DeleteDialog from "@/components/shared/DeleteDialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/shared/DropdownMenu";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator";
import { useEnvironment } from "@/lib/environments/environments";
import { createSurvey, deleteSurvey, duplicateSurvey, useSurveys } from "@/lib/surveys/surveys";
import { Badge, ErrorComponent } from "@formbricks/ui";
import {
ComputerDesktopIcon,
DocumentDuplicateIcon,
EllipsisHorizontalIcon,
LinkIcon,
PencilSquareIcon,
EyeIcon,
TrashIcon,
PlusIcon,
ArrowUpOnSquareStackIcon,
} from "@heroicons/react/24/solid";
import { Badge } from "@formbricks/ui";
import { ComputerDesktopIcon, LinkIcon, PlusIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import TemplateList from "./templates/TemplateList";
import { useEffect } from "react";
import { changeEnvironment } from "@/lib/environments/changeEnvironments";
import { TProduct } from "@formbricks/types/v1/product";
import SurveyDropDownMenu from "@/app/(app)/environments/[environmentId]/surveys/SurveyDropDownMenu";
import SurveyStarter from "@/app/(app)/environments/[environmentId]/surveys/SurveyStarter";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { getEnvironment, getEnvironments } from "@formbricks/lib/services/environment";
import { getSurveysWithAnalytics } from "@formbricks/lib/services/survey";
import type { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import type { TEnvironment } from "@formbricks/types/v1/environment";
interface SurveyListProps {
environmentId: string;
product: TProduct;
}
export default function SurveysList({ environmentId, product }: SurveyListProps) {
const router = useRouter();
const { surveys, mutateSurveys, isLoadingSurveys, isErrorSurveys } = useSurveys(environmentId);
const { environment, isErrorEnvironment, isLoadingEnvironment } = useEnvironment(environmentId);
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isCreateSurveyLoading, setIsCreateSurveyLoading] = useState(false);
const [activeSurvey, setActiveSurvey] = useState("" as any);
const [activeSurveyIdx, setActiveSurveyIdx] = useState("" as any);
const [otherEnvironment, setOtherEnvironment] = useState("" as any);
useEffect(() => {
if (environment) {
setOtherEnvironment(environment.product.environments.find((e) => e.type !== environment.type));
}
}, [environment]);
const newSurvey = async () => {
router.push(`/environments/${environmentId}/surveys/templates`);
};
const newSurveyFromTemplate = async (template: Template) => {
setIsCreateSurveyLoading(true);
const augmentedTemplate = {
...template.preset,
type: environment?.widgetSetupCompleted ? "web" : "link",
};
try {
const survey = await createSurvey(environmentId, augmentedTemplate);
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
} catch (e) {
toast.error("An error occured creating a new survey");
setIsCreateSurveyLoading(false);
}
};
const deleteSurveyAction = async (survey, surveyIdx) => {
try {
await deleteSurvey(environmentId, survey.id);
// remove locally
const updatedsurveys = JSON.parse(JSON.stringify(surveys));
updatedsurveys.splice(surveyIdx, 1);
mutateSurveys(updatedsurveys);
setDeleteDialogOpen(false);
toast.success("Survey deleted successfully.");
} catch (error) {
console.error(error);
}
};
const duplicateSurveyAndRefresh = async (surveyId) => {
try {
await duplicateSurvey(environmentId, surveyId);
mutateSurveys();
toast.success("Survey duplicated successfully.");
} catch (error) {
toast.error("Failed to duplicate the survey.");
}
};
const copyToOtherEnvironment = async (surveyId) => {
try {
await duplicateSurvey(environmentId, surveyId, otherEnvironment.id);
if (otherEnvironment.type === "production") {
toast.success("Survey copied to production env.");
} else if (otherEnvironment.type === "development") {
toast.success("Survey copied to development env.");
}
changeEnvironment(otherEnvironment.type, environment, router);
} catch (error) {
toast.error(`Failed to copy to ${otherEnvironment.type}`);
}
};
if (isLoadingSurveys || isLoadingEnvironment) {
return <LoadingSpinner />;
}
if (isErrorSurveys || isErrorEnvironment) {
return <ErrorComponent />;
}
export default async function SurveysList({ environmentId }: { environmentId: string }) {
const product = await getProductByEnvironmentId(environmentId);
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);
if (surveys.length === 0) {
return (
<div className="mx-auto flex w-full max-w-5xl flex-col py-12">
{isCreateSurveyLoading ? (
<LoadingSpinner />
) : (
<>
<div className="px-7 pb-4">
<h1 className="text-3xl font-extrabold text-slate-700">
You&apos;re all set! Time to create your first survey.
</h1>
</div>
<TemplateList
environmentId={environmentId}
onTemplateClick={(template) => {
newSurveyFromTemplate(template);
}}
environment={environment}
product={product}
/>
</>
)}
</div>
);
return <SurveyStarter environmentId={environmentId} environment={environment} product={product} />;
}
return (
<>
<ul className="grid grid-cols-2 place-content-stretch gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-5 ">
<button onClick={() => newSurvey()}>
<Link href={`/environments/${environmentId}/surveys/templates`}>
<li className="col-span-1 h-56">
<div className="delay-50 flex h-full items-center justify-center overflow-hidden rounded-md bg-gradient-to-br from-slate-900 to-slate-800 font-light text-white shadow transition ease-in-out hover:scale-105 hover:from-slate-800 hover:to-slate-700">
<div id="main-cta" className="px-4 py-8 sm:p-14 xl:p-10">
@@ -159,10 +33,10 @@ export default function SurveysList({ environmentId, product }: SurveyListProps)
</div>
</div>
</li>
</button>
</Link>
{surveys
.sort((a, b) => b.updatedAt - a.updatedAt)
.map((survey, surveyIdx) => (
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
.map((survey) => (
<li key={survey.id} className="relative col-span-1 h-56">
<div className="delay-50 flex h-full flex-col justify-between rounded-md bg-white shadow transition ease-in-out hover:scale-105">
<div className="px-6 py-4">
@@ -198,119 +72,28 @@ export default function SurveysList({ environmentId, product }: SurveyListProps)
tooltip
environmentId={environmentId}
/>
<p className="ml-2 text-xs text-slate-400 ">{survey._count?.responses} responses</p>
<p className="ml-2 text-xs text-slate-400 ">
{survey.analytics.numResponses} responses
</p>
</>
)}
{survey.status === "draft" && (
<span className="text-xs italic text-slate-400">Draft</span>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
<div>
<span className="sr-only">Open options</span>
<EllipsisHorizontalIcon className="h-5 w-5" aria-hidden="true" />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-40">
<DropdownMenuGroup>
<DropdownMenuItem>
<Link
className="flex w-full items-center"
href={`/environments/${environmentId}/surveys/${survey.id}/edit`}>
<PencilSquareIcon className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<button
className="flex w-full items-center"
onClick={async () => {
duplicateSurveyAndRefresh(survey.id);
}}>
<DocumentDuplicateIcon className="mr-2 h-4 w-4" />
Duplicate
</button>
</DropdownMenuItem>
{environment.type === "development" ? (
<DropdownMenuItem>
<button
className="flex w-full items-center"
onClick={() => {
copyToOtherEnvironment(survey.id);
}}>
<ArrowUpOnSquareStackIcon className="mr-2 h-4 w-4" />
Copy to Prod
</button>
</DropdownMenuItem>
) : environment.type === "production" ? (
<DropdownMenuItem>
<button
className="flex w-full items-center"
onClick={() => {
copyToOtherEnvironment(survey.id);
}}>
<ArrowUpOnSquareStackIcon className="mr-2 h-4 w-4" />
Copy to Dev
</button>
</DropdownMenuItem>
) : null}
{survey.type === "link" && survey.status !== "draft" && (
<>
<DropdownMenuItem>
<Link
className="flex w-full items-center"
href={`${window.location.protocol}//${window.location.host}/s/${survey.id}?preview=true`}
target="_blank">
<EyeIcon className="mr-2 h-4 w-4" />
Preview Survey
</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<button
className="flex w-full items-center"
onClick={() => {
navigator.clipboard.writeText(
`${window.location.protocol}//${window.location.host}/s/${survey.id}`
);
toast.success("Copied link to clipboard");
}}>
<LinkIcon className="mr-2 h-4 w-4" />
Copy Link
</button>
</DropdownMenuItem>
</>
)}
<DropdownMenuItem>
<button
className="flex w-full items-center"
onClick={() => {
setActiveSurvey(survey);
setActiveSurveyIdx(surveyIdx);
setDeleteDialogOpen(true);
}}>
<TrashIcon className="mr-2 h-4 w-4" />
Delete
</button>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<SurveyDropDownMenu
survey={survey}
key={`survey-${survey.id}`}
environmentId={environmentId}
environment={environment}
otherEnvironment={otherEnvironment}
/>
</div>
</div>
</div>
</li>
))}
</ul>
<DeleteDialog
deleteWhat="Survey"
open={isDeleteDialogOpen}
setOpen={setDeleteDialogOpen}
onDelete={() => deleteSurveyAction(activeSurvey, activeSurveyIdx)}
text="Are you sure you want to delete this survey and all of its responses? This action cannot be undone."
/>
</>
);
}

View File

@@ -0,0 +1,60 @@
"use client";
import { Template } from "@/../../packages/types/templates";
import { createSurveyAction } from "./actions";
import TemplateList from "@/app/(app)/environments/[environmentId]/surveys/templates/TemplateList";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import type { TEnvironment } from "@formbricks/types/v1/environment";
import type { TProduct } from "@formbricks/types/v1/product";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "react-hot-toast";
export default function SurveyStarter({
environmentId,
environment,
product,
}: {
environmentId: string;
environment: TEnvironment;
product: TProduct;
}) {
const [isCreateSurveyLoading, setIsCreateSurveyLoading] = useState(false);
const router = useRouter();
const newSurveyFromTemplate = async (template: Template) => {
setIsCreateSurveyLoading(true);
const augmentedTemplate = {
...template.preset,
type: environment?.widgetSetupCompleted ? "web" : "link",
};
try {
const survey = await createSurveyAction(environmentId, augmentedTemplate);
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
} catch (e) {
toast.error("An error occured creating a new survey");
setIsCreateSurveyLoading(false);
}
};
return (
<div className="mx-auto flex w-full max-w-5xl flex-col py-12">
{isCreateSurveyLoading ? (
<LoadingSpinner />
) : (
<>
<div className="px-7 pb-4">
<h1 className="text-3xl font-extrabold text-slate-700">
You&apos;re all set! Time to create your first survey.
</h1>
</div>
<TemplateList
environmentId={environmentId}
onTemplateClick={(template) => {
newSurveyFromTemplate(template);
}}
environment={environment}
product={product}
/>
</>
)}
</div>
);
}

View File

@@ -1,11 +1,11 @@
import { IS_FORMBRICKS_CLOUD, RESPONSES_LIMIT_FREE } from "@formbricks/lib/constants";
import { getSurveyResponses } from "@formbricks/lib/services/response";
import { getSurvey } from "@formbricks/lib/services/survey";
import { getSurveyWithAnalytics } from "@formbricks/lib/services/survey";
import { getTeamByEnvironmentId } from "@formbricks/lib/services/team";
export const getAnalysisData = async (surveyId: string, environmentId: string) => {
const [survey, team, allResponses] = await Promise.all([
getSurvey(surveyId),
getSurveyWithAnalytics(surveyId),
getTeamByEnvironmentId(environmentId),
getSurveyResponses(surveyId),
]);

View File

@@ -0,0 +1,7 @@
"use server";
import { createSurvey } from "@formbricks/lib/services/survey";
export async function createSurveyAction(environmentId: string, surveyBody: any) {
return await createSurvey(environmentId, surveyBody);
}

View File

@@ -0,0 +1,5 @@
import LoadingSpinner from "@/components/shared/LoadingSpinner";
export default function LoadingPage() {
return <LoadingSpinner />;
}

View File

@@ -2,19 +2,13 @@ import ContentWrapper from "@/components/shared/ContentWrapper";
import WidgetStatusIndicator from "@/components/shared/WidgetStatusIndicator";
import SurveysList from "./SurveyList";
import { Metadata } from "next";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
export const metadata: Metadata = {
title: "Your Surveys",
};
export default async function SurveysPage({ params }) {
const environmentId = params.environmentId;
const product = await getProductByEnvironmentId(environmentId);
return (
<ContentWrapper className="flex h-full flex-col justify-between">
<SurveysList environmentId={params.environmentId} product={product} />
<SurveysList environmentId={params.environmentId} />
<WidgetStatusIndicator environmentId={params.environmentId} type="mini" />
</ContentWrapper>
);

View File

@@ -1,21 +1,27 @@
"use client";
import { createSurvey } from "@/lib/surveys/surveys";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useProfile } from "@/lib/profile";
import { replacePresetPlaceholders } from "@/lib/templates";
import { cn } from "@formbricks/lib/cn";
import type { Template } from "@formbricks/types/templates";
import { Button, ErrorComponent } from "@formbricks/ui";
import { PlusCircleIcon } from "@heroicons/react/24/outline";
import { SparklesIcon } from "@heroicons/react/24/solid";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { customSurvey, templates } from "./templates";
import { SplitIcon } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
import type { TEnvironment } from "@formbricks/types/v1/environment";
import type { TProduct } from "@formbricks/types/v1/product";
import { useProfile } from "@/lib/profile";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import {
Button,
ErrorComponent,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@formbricks/ui";
import { PlusCircleIcon } from "@heroicons/react/24/outline";
import { SparklesIcon } from "@heroicons/react/24/solid";
import { SplitIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { createSurveyAction } from "./actions";
import { customSurvey, templates } from "./templates";
type TemplateList = {
environmentId: string;
@@ -59,7 +65,7 @@ export default function TemplateList({ environmentId, onTemplateClick, product,
...activeTemplate.preset,
type: environment?.widgetSetupCompleted ? "web" : "link",
};
const survey = await createSurvey(environmentId, augmentedTemplate);
const survey = await createSurveyAction(environmentId, augmentedTemplate);
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
};

View File

@@ -0,0 +1,7 @@
"use server";
import { createSurvey } from "@formbricks/lib/services/survey";
export async function createSurveyAction(environmentId: string, surveyBody: any) {
return await createSurvey(environmentId, surveyBody);
}

View File

@@ -1,5 +1,5 @@
import { prisma } from "@formbricks/database";
import { select } from "@formbricks/lib/services/survey";
import { selectSurvey } from "@formbricks/lib/services/survey";
import { TPerson } from "@formbricks/types/v1/people";
import { TSurvey } from "@formbricks/types/v1/surveys";
@@ -48,7 +48,7 @@ export const getSurveys = async (environmentId: string, person: TPerson): Promis
],
},
select: {
...select,
...selectSurvey,
attributeFilters: {
select: {
id: true,

View File

@@ -1,3 +1,5 @@
"use client";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
import { useEnvironment } from "@/lib/environments/environments";
import { ArchiveBoxIcon, CheckIcon, PauseIcon } from "@heroicons/react/24/solid";

View File

@@ -37,3 +37,43 @@ export const getEnvironment = cache(async (environmentId: string): Promise<TEnvi
throw new ValidationError("Data validation of environment failed");
}
});
export const getEnvironments = cache(
async (productId: string): Promise<TEnvironment[]> => {
let productPrisma;
try {
productPrisma = await prisma.product.findFirst({
where: {
id: productId,
},
include:{
environments:true
}
});
if (!productPrisma) {
throw new ResourceNotFoundError("Product", productId);
}
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
const environments:TEnvironment[]=[];
for(let environment of productPrisma.environments){
let targetEnvironment:TEnvironment=ZEnvironment.parse(environment);
environments.push(targetEnvironment);
}
try {
return environments;
} catch (error) {
if (error instanceof z.ZodError) {
console.error(JSON.stringify(error.errors, null, 2));
}
throw new ValidationError("Data validation of environments array failed");
}
}
);

View File

@@ -41,3 +41,4 @@ export const getProductByEnvironmentId = cache(async (environmentId: string): Pr
throw new ValidationError("Data validation of product failed");
}
});

View File

@@ -1,13 +1,13 @@
import { prisma } from "@formbricks/database";
import { z } from "zod";
import { ValidationError } from "@formbricks/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/errors";
import { TSurvey, TSurveyWithAnalytics, ZSurvey, ZSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { Prisma } from "@prisma/client";
import "server-only";
import { cache } from "react";
import "server-only";
import { z } from "zod";
import { captureTelemetry } from "../telemetry";
export const select = {
export const selectSurveyWithAnalytics = {
id: true,
createdAt: true,
updatedAt: true,
@@ -23,6 +23,61 @@ export const select = {
closeOnDate: true,
delay: true,
autoComplete: true,
redirectUrl: true,
triggers: {
select: {
eventClass: {
select: {
id: true,
createdAt: true,
updatedAt: true,
environmentId: true,
name: true,
description: true,
type: true,
noCodeConfig: true,
},
},
},
},
attributeFilters: {
select: {
id: true,
attributeClassId: true,
condition: true,
value: true,
},
},
displays: {
select: {
status: true,
id: true,
},
},
_count: {
select: {
responses: true,
},
},
};
export const selectSurvey = {
id: true,
createdAt: true,
updatedAt: true,
name: true,
type: true,
environmentId: true,
status: true,
questions: true,
thankYouCard: true,
displayOption: true,
recontactDays: true,
autoClose: true,
closeOnDate: true,
delay: true,
autoComplete: true,
redirectUrl: true,
triggers: {
select: {
eventClass: {
@@ -49,18 +104,70 @@ export const select = {
},
};
export const preloadSurvey = (surveyId: string) => {
void getSurvey(surveyId);
export const preloadSurveyWithAnalytics = (surveyId: string) => {
void getSurveyWithAnalytics(surveyId);
};
export const getSurvey = cache(async (surveyId: string): Promise<TSurveyWithAnalytics | null> => {
export const getSurveyWithAnalytics = cache(
async (surveyId: string): Promise<TSurveyWithAnalytics | null> => {
let surveyPrisma;
try {
surveyPrisma = await prisma.survey.findUnique({
where: {
id: surveyId,
},
select: selectSurveyWithAnalytics,
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
if (!surveyPrisma) {
throw new ResourceNotFoundError("Survey", surveyId);
}
let { _count, displays, ...surveyPrismaFields } = surveyPrisma;
const numDisplays = displays.length;
const numDisplaysResponded = displays.filter((item) => item.status === "responded").length;
const numResponses = _count.responses;
// responseRate, rounded to 2 decimal places
const responseRate = numDisplays ? Math.round((numDisplaysResponded / numDisplays) * 100) / 100 : 0;
const transformedSurvey = {
...surveyPrismaFields,
triggers: surveyPrismaFields.triggers.map((trigger) => trigger.eventClass),
analytics: {
numDisplays,
responseRate,
numResponses,
},
};
try {
const survey = ZSurveyWithAnalytics.parse(transformedSurvey);
return survey;
} catch (error) {
if (error instanceof z.ZodError) {
console.error(JSON.stringify(error.errors, null, 2)); // log the detailed error information
}
throw new ValidationError("Data validation of survey failed");
}
}
);
export const getSurvey = cache(async (surveyId: string): Promise<TSurvey | null> => {
let surveyPrisma;
try {
surveyPrisma = await prisma.survey.findUnique({
where: {
id: surveyId,
},
select,
select: selectSurvey,
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -71,36 +178,16 @@ export const getSurvey = cache(async (surveyId: string): Promise<TSurveyWithAnal
}
if (!surveyPrisma) {
throw new ResourceNotFoundError("Survey", surveyId);
return null;
}
const numDisplays = await prisma.display.count({
where: {
surveyId,
},
});
const numDisplaysResponded = await prisma.display.count({
where: {
surveyId,
status: "responded",
},
});
// responseRate, rounded to 2 decimal places
const responseRate = numDisplays ? Math.round((numDisplaysResponded / numDisplays) * 100) / 100 : 0;
const transformedSurvey = {
...surveyPrisma,
triggers: surveyPrisma.triggers.map((trigger) => trigger.eventClass),
analytics: {
numDisplays,
responseRate,
},
};
try {
const survey = ZSurveyWithAnalytics.parse(transformedSurvey);
const survey = ZSurvey.parse(transformedSurvey);
return survey;
} catch (error) {
if (error instanceof z.ZodError) {
@@ -117,7 +204,7 @@ export const getSurveys = cache(async (environmentId: string): Promise<TSurvey[]
where: {
environmentId,
},
select,
select: selectSurvey,
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -128,16 +215,16 @@ export const getSurveys = cache(async (environmentId: string): Promise<TSurvey[]
}
const surveys: TSurvey[] = [];
for (const surveyPrisma of surveysPrisma) {
const transformedSurvey = {
...surveyPrisma,
triggers: surveyPrisma.triggers.map((trigger) => trigger.eventClass),
};
const survey = ZSurvey.parse(transformedSurvey);
surveys.push(survey);
}
try {
for (const surveyPrisma of surveysPrisma) {
const transformedSurvey = {
...surveyPrisma,
triggers: surveyPrisma.triggers.map((trigger) => trigger.eventClass),
};
const survey = ZSurvey.parse(transformedSurvey);
surveys.push(survey);
}
return surveys;
} catch (error) {
if (error instanceof z.ZodError) {
@@ -146,3 +233,76 @@ export const getSurveys = cache(async (environmentId: string): Promise<TSurvey[]
throw new ValidationError("Data validation of survey failed");
}
});
export const getSurveysWithAnalytics = cache(
async (environmentId: string): Promise<TSurveyWithAnalytics[]> => {
let surveysPrisma;
try {
surveysPrisma = await prisma.survey.findMany({
where: {
environmentId,
},
select: selectSurveyWithAnalytics,
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
try {
const surveys: TSurveyWithAnalytics[] = [];
for (const { _count, displays, ...surveyPrisma } of surveysPrisma) {
const numDisplays = displays.length;
const numDisplaysResponded = displays.filter((item) => item.status === "responded").length;
const responseRate = numDisplays ? Math.round((numDisplaysResponded / numDisplays) * 100) / 100 : 0;
const transformedSurvey = {
...surveyPrisma,
triggers: surveyPrisma.triggers.map((trigger) => trigger.eventClass),
analytics: {
numDisplays,
responseRate,
numResponses: _count.responses,
},
};
const survey = ZSurveyWithAnalytics.parse(transformedSurvey);
surveys.push(survey);
}
return surveys;
} catch (error) {
if (error instanceof z.ZodError) {
console.error(JSON.stringify(error.errors, null, 2)); // log the detailed error information
}
throw new ValidationError("Data validation of survey failed");
}
}
);
export async function deleteSurvey(surveyId: string) {
const deletedSurvey = await prisma.survey.delete({
where: {
id: surveyId,
},
select: selectSurvey,
});
return deletedSurvey;
}
export async function createSurvey(environmentId: string, surveyBody: any) {
const survey = await prisma.survey.create({
data: {
...surveyBody,
environment: {
connect: {
id: environmentId,
},
},
},
});
captureTelemetry("survey created");
return survey;
}

View File

@@ -231,13 +231,14 @@ export const ZSurvey = z.object({
displayOption: z.enum(["displayOnce", "displayMultiple", "respondMultiple"]),
autoClose: z.union([z.number(), z.null()]),
triggers: z.array(ZActionClass),
redirectUrl: z.string().url().optional(),
redirectUrl: z.string().url().nullable(),
recontactDays: z.union([z.number(), z.null()]),
questions: ZSurveyQuestions,
thankYouCard: ZSurveyThankYouCard,
delay: z.number(),
autoComplete: z.union([z.number(), z.null()]),
closeOnDate: z.date().nullable(),
surveyClosedMessage: ZSurveyClosedMessage,
});
export type TSurvey = z.infer<typeof ZSurvey>;
@@ -246,8 +247,8 @@ export const ZSurveyWithAnalytics = ZSurvey.extend({
analytics: z.object({
numDisplays: z.number(),
responseRate: z.number(),
numResponses: z.number(),
}),
surveyClosedMessage: ZSurveyClosedMessage,
});
export type TSurveyWithAnalytics = z.infer<typeof ZSurveyWithAnalytics>;