mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-21 19:39:28 -05:00
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:
@@ -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>
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
+154
@@ -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.`);
|
||||
}
|
||||
}
|
||||
+8
-2
@@ -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,
|
||||
Reference in New Issue
Block a user