Add ability to delete products and responses (#276)

* #263 long strings edge cases fixed

* Add ability to delete products and responses

---------

Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Shubhdeep Chhabra
2023-05-16 17:53:14 +05:30
committed by GitHub
parent eea8500501
commit c352c9ff5d
10 changed files with 179 additions and 14 deletions

View File

@@ -280,7 +280,7 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
<DropdownMenuContent className="w-56">
<DropdownMenuLabel className="cursor-default break-all">
<span className="ph-no-capture font-normal">Signed in as </span>
{session.user.name.length > 30 ? (
{session?.user?.name.length > 30 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>

View File

@@ -81,7 +81,7 @@ export default function PersonDetails({ environmentId, personId }: PersonDetails
onClick={() => {
setDeleteDialogOpen(true);
}}>
<TrashIcon className="h-5 w-5 text-slate-500 hover:text-red-500" />
<TrashIcon className="h-5 w-5 text-slate-500 hover:text-red-700" />
</button>
</div>
</div>

View File

@@ -1,12 +1,21 @@
"use client";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useEnvironment } from "@/lib/environments/environments";
import { useProductMutation } from "@/lib/products/mutateProducts";
import { useProduct } from "@/lib/products/products";
import { Button, ErrorComponent, Input, Label } from "@formbricks/ui";
import { useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useRouter } from "next/navigation";
import DeleteDialog from "@/components/shared/DeleteDialog";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { deleteProduct, useProduct } from "@/lib/products/products";
import { truncate } from "@/lib/utils";
import { useEnvironment } from "@/lib/environments/environments";
import { useProductMutation } from "@/lib/products/mutateProducts";
import { Button, ErrorComponent, Input, Label } from "@formbricks/ui";
import { useProfile } from "@/lib/profile";
import { useMembers } from "@/lib/members";
export function EditProductName({ environmentId }) {
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
@@ -88,3 +97,73 @@ export function EditWaitingTime({ environmentId }) {
</form>
);
}
export function DeleteProduct({ environmentId }) {
const router = useRouter();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const { profile } = useProfile();
const{ team } = useMembers(environmentId)
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
const { environment } = useEnvironment(environmentId);
const availableProducts = environment?.availableProducts?.length;
const role = team?.members?.filter(member => member?.userId === profile?.id)[0]?.role;
const isUserAdminOrOwner = role === 'admin' || role === 'owner';
const isDeleteDisabled = availableProducts <= 1 || !isUserAdminOrOwner;
if (isLoadingProduct) {
return <LoadingSpinner />;
}
if (isErrorProduct) {
return <ErrorComponent />;
}
const handleDeleteProduct = async () => {
if (environment?.availableProducts?.length <= 1) {
toast.error("Cannot delete product. Environment needs at least 1.");
setIsDeleteDialogOpen(false);
return;
}
const deleteResponse = await deleteProduct(environmentId);
if (deleteResponse?.message?.length > 0) {
toast.error(deleteResponse.message);
setIsDeleteDialogOpen(false);
}
if (deleteResponse?.id?.length > 0) {
toast.success("Product deleted successfully.");
router.push("/environments");
}
}
return (
<div>
<p className="text-sm text-slate-900">
Here you can delete&nbsp;
<strong>{truncate(product?.name, 30)}</strong>
&nbsp;incl. all surveys, responses, people, actions and attributes.{" "}
<strong>This action cannot be undone.</strong>
</p>
<Button disabled={isDeleteDisabled} variant="warn" className={`mt-4 ${isDeleteDisabled ? 'ring-1 ring-offset-1 ring-grey-500' : ''}`} onClick={() => setIsDeleteDialogOpen(true)}>
Delete
</Button>
{isDeleteDisabled && (
<p className="text-xs text-red-700 mt-2">
{!isUserAdminOrOwner
? 'Only Admin or Owners can delete products.'
: 'Environment needs at least 1 product.'
}
</p>
)}
<DeleteDialog
deleteWhat="Product"
open={isDeleteDialogOpen}
setOpen={setIsDeleteDialogOpen}
onDelete={handleDeleteProduct}
text="Are you sure you want to delete this Product? This action cannot be undone."
/>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import SettingsCard from "../SettingsCard";
import SettingsTitle from "../SettingsTitle";
import { EditProductName, EditWaitingTime } from "./editProduct";
import { EditProductName, EditWaitingTime, DeleteProduct } from "./editProduct";
export default function ProfileSettingsPage({ params }) {
return (
@@ -14,6 +14,11 @@ export default function ProfileSettingsPage({ params }) {
description="Control how frequently users can be surveyed across all surveys.">
<EditWaitingTime environmentId={params.environmentId} />
</SettingsCard>
<SettingsCard
title="Danger Zone"
description="You will delete all surveys, responses, people, actions and attributes along with the product.">
<DeleteProduct environmentId={params.environmentId} />
</SettingsCard>
</div>
);
}

View File

@@ -68,7 +68,7 @@ export default function ResponseTimeline({ environmentId, surveyId }) {
<div>
{matchQandA.map((updatedResponse) => {
return (
<SingleResponse key={updatedResponse.id} data={updatedResponse} environmentId={environmentId} />
<SingleResponse key={updatedResponse.id} data={updatedResponse} surveyId={surveyId} environmentId={environmentId} />
);
})}
</div>

View File

@@ -1,13 +1,19 @@
import DeleteDialog from "@/components/shared/DeleteDialog";
import { timeSince } from "@formbricks/lib/time";
import { PersonAvatar } from "@formbricks/ui";
import { CheckCircleIcon } from "@heroicons/react/24/solid";
import { TrashIcon } from "@heroicons/react/24/outline";
import Link from "next/link";
import { useState } from "react";
import toast from "react-hot-toast";
import { RatingResponse } from "../RatingResponse";
import { deleteSubmission, useResponses } from "@/lib/responses/responses";
interface OpenTextSummaryProps {
data: {
id: string;
personId: string;
surveyId: string,
person: {
id: string;
createdAt: string;
@@ -28,6 +34,7 @@ interface OpenTextSummaryProps {
}[];
};
environmentId: string;
surveyId: string;
}
function findEmail(person) {
@@ -35,9 +42,19 @@ function findEmail(person) {
return emailAttribute ? emailAttribute.value : null;
}
export default function SingleResponse({ data, environmentId }: OpenTextSummaryProps) {
export default function SingleResponse({ data, environmentId, surveyId }: OpenTextSummaryProps) {
const email = data.person && findEmail(data.person);
const displayIdentifier = email || data.personId;
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const { mutateResponses } = useResponses(environmentId, surveyId)
const handleDeleteSubmission = async () => {
const deleteResponse = await deleteSubmission(environmentId, data?.surveyId, data?.id);
mutateResponses();
if(deleteResponse?.id?.length > 0)
toast.success("Submission deleted successfully.");
setDeleteDialogOpen(false);
};
console.log(data);
@@ -67,6 +84,14 @@ export default function SingleResponse({ data, environmentId }: OpenTextSummaryP
Completed <CheckCircleIcon className="ml-1 h-5 w-5 text-green-400" />
</span>
)}
<div className="flex items-center space-x-3">
<button
onClick={() => {
setDeleteDialogOpen(true);
}}>
<TrashIcon className="h-4 w-4 text-slate-500 hover:text-red-700" />
</button>
</div>
<time className="text-slate-500" dateTime={timeSince(data.updatedAt)}>
{timeSince(data.updatedAt)}
</time>
@@ -91,6 +116,12 @@ export default function SingleResponse({ data, environmentId }: OpenTextSummaryP
</div>
))}
</div>
<DeleteDialog
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
deleteWhat="response"
onDelete={handleDeleteSubmission}
/>
</div>
);
}

View File

@@ -16,7 +16,7 @@ export const useProduct = (environmentId: string) => {
};
};
export const createProduct = async (environmentId, product: { name: string }) => {
export const createProduct = async (environmentId: string, product: { name: string }) => {
const response = await fetch(`/api/v1/environments/${environmentId}/product`, {
method: "POST",
headers: {
@@ -27,3 +27,11 @@ export const createProduct = async (environmentId, product: { name: string }) =>
return response.json();
};
export const deleteProduct = async (environmentId: string) => {
const response = await fetch(`/api/v1/environments/${environmentId}/product`, {
method: "DELETE",
});
return response.json();
};

View File

@@ -11,6 +11,14 @@ export const useResponses = (environmentId: string, surveyId: string) => {
responsesData: data,
isLoadingResponses: isLoading,
isErrorResponses: error,
mutateRespones: mutate,
mutateResponses: mutate,
};
};
export const deleteSubmission = async (environmentId: string, surveyId: string, responseId: string) => {
const response = await fetch(`/api/v1/environments/${environmentId}/surveys/${surveyId}/responses/${responseId}`, {
method: "DELETE",
});
return response.json();
};

View File

@@ -1,4 +1,4 @@
import { hasEnvironmentAccess } from "@/lib/api/apiHelper";
import { getSessionUser, hasEnvironmentAccess } from "@/lib/api/apiHelper";
import { prisma } from "@formbricks/database";
import { EnvironmentType } from "@prisma/client";
import { populateEnvironment } from "@/lib/populate";
@@ -6,6 +6,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
const environmentId = req.query?.environmentId?.toString();
const currentUser: any = await getSessionUser(req, res);
const hasAccess = await hasEnvironmentAccess(req, res, environmentId);
if (!hasAccess) {
@@ -114,6 +115,38 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
const firstEnvironment = newProduct.environments[0];
res.json(firstEnvironment);
}
// DELETE
else if (req.method === "DELETE") {
const membership = await prisma.membership.findUnique({
where: {
userId_teamId: {
userId: currentUser.id,
teamId: currentUser.teamId,
},
},
});
if (membership?.role !== "admin" && membership?.role !== "owner") {
return res.status(403).json({ message: "You are not allowed to delete products." });
}
const environment = await prisma.environment.findUnique({
where: { id: environmentId },
select: {
productId: true,
},
});
if (environment === null) {
return res.status(404).json({ message: "This environment doesn't exist" });
}
// Delete the product with
const prismaRes = await prisma.product.delete({
where: { id: environment.productId },
});
return res.json(prismaRes);
}
// Unknown HTTP Method
else {

View File

@@ -39,8 +39,9 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
// Delete /api/environments[environmentId]/surveys/[surveyId]/responses/[responseId]
// Deletes a single survey
else if (req.method === "DELETE") {
const submissionId = req.query.submissionId?.toString();
const prismaRes = await prisma.response.delete({
where: { id: responseId },
where: { id: submissionId },
});
return res.json(prismaRes);
}