Add option to copy surveys to other environments (#392)

* Add option to copy surveys to other environments

---------

Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Ankur Datta
2023-06-23 15:18:28 +05:30
committed by GitHub
parent a1bbe5c5fb
commit c224e7995d
4 changed files with 248 additions and 32 deletions
@@ -14,7 +14,6 @@ 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 { PlusIcon } from "@heroicons/react/24/outline";
import {
ComputerDesktopIcon,
DocumentDuplicateIcon,
@@ -23,23 +22,34 @@ import {
PencilSquareIcon,
EyeIcon,
TrashIcon,
PlusIcon,
ArrowUpOnSquareStackIcon,
} 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";
export default function SurveysList({ environmentId }) {
const router = useRouter();
const { surveys, mutateSurveys, isLoadingSurveys, isErrorSurveys } = useSurveys(environmentId);
const { environment } = useEnvironment(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`);
@@ -84,11 +94,25 @@ export default function SurveysList({ environmentId }) {
}
};
if (isLoadingSurveys) {
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) {
if (isErrorSurveys || isErrorEnvironment) {
return <ErrorComponent />;
}
@@ -202,26 +226,29 @@ export default function SurveysList({ environmentId }) {
Duplicate
</button>
</DropdownMenuItem>
{/* <DropdownMenuItem>
<Link
className="flex w-full items-center"
href={`/environments/${environmentId}/surveys/${survey.id}/edit`}>
<ArrowUturnUpIcon className="mr-2 h-4 w-4" />
Copy to Production
</Link>
</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>
{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>
@@ -248,6 +275,18 @@ export default function SurveysList({ environmentId }) {
</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>
+23 -6
View File
@@ -104,13 +104,30 @@ export const getSurveyPage = (survey, pageId) => {
return page;
};
export const duplicateSurvey = async (environmentId: string, surveyId: string) => {
// used to duplicate the survey in the same environment when targetEnvironment is null and
// used to duplicate the survey in a different environment when targetEnvironment is not null
export const duplicateSurvey = async (
environmentId: string,
surveyId: string,
targetEnvironmentId: string | undefined = undefined
) => {
try {
const res = await fetch(`/api/v1/environments/${environmentId}/surveys/${surveyId}/duplicate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
return await res.json();
if (targetEnvironmentId === undefined) {
const res = await fetch(`/api/v1/environments/${environmentId}/surveys/${surveyId}/duplicate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
return await res.json();
} else {
const res = await fetch(
`/api/v1/environments/${environmentId}/surveys/${surveyId}/duplicate/${targetEnvironmentId}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
}
);
return await res.json();
}
} catch (error) {
console.error(error);
throw Error(`duplicateSurvey: unable to duplicate survey: ${error.message}`);
@@ -0,0 +1,154 @@
import { hasEnvironmentAccess } from "@/lib/api/apiHelper";
import { prisma } from "@formbricks/database";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
const environmentId = req.query.environmentId?.toString();
const targetEnvironmentId = req.query.targetEnvironmentId?.toString();
const surveyId = req.query.surveyId?.toString();
if (environmentId === undefined) {
return res.status(400).json({ message: "Missing environmentId" });
}
if (surveyId === undefined) {
return res.status(400).json({ message: "Missing surveyId" });
}
const hasAccess = await hasEnvironmentAccess(req, res, environmentId);
const hasTargetEnvAccess = await hasEnvironmentAccess(req, res, targetEnvironmentId);
if (!hasAccess || !hasTargetEnvAccess) {
return res.status(403).json({ message: "Not authorized" });
}
// POST
else if (req.method === "POST") {
// duplicate current survey including its triggers
const existingSurvey = await prisma.survey.findFirst({
where: {
id: surveyId,
environmentId,
},
include: {
triggers: {
include: {
eventClass: true,
},
},
attributeFilters: {
include: {
attributeClass: true,
},
},
},
});
if (!existingSurvey) {
return res.status(404).json({ message: "Survey not found" });
}
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,
},
},
},
});
return res.json(newSurvey);
}
// Unknown HTTP Method
else {
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);
}
}
@@ -29,6 +29,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
},
include: {
triggers: true,
attributeFilters: true,
},
});
@@ -43,8 +44,6 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
id: undefined, // id is auto-generated
environmentId: undefined, // environmentId is set below
name: `${existingSurvey.name} (copy)`,
createdAt: new Date(),
updatedAt: new Date(),
status: "draft",
questions: JSON.parse(JSON.stringify(existingSurvey.questions)),
thankYouCard: JSON.parse(JSON.stringify(existingSurvey.thankYouCard)),
@@ -53,6 +52,13 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
eventClassId: trigger.eventClassId,
})),
},
attributeFilters: {
create: existingSurvey.attributeFilters.map((attributeFilter) => ({
attributeClassId: attributeFilter.attributeClassId,
condition: attributeFilter.condition,
value: attributeFilter.value,
})),
},
environment: {
connect: {
id: environmentId,