Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

515 survey overview page refactor #554

Merged
176 changes: 175 additions & 1 deletion apps/web/app/(app)/environments/[environmentId]/actions.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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);
};
Original file line number Diff line number Diff line change
@@ -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."
/>
</>
);
}