Feature create new products (#220)

* feat: add modal to add new product to current team

* feat: add create product endpoint and helper function

* feat: display available products in dropdown and include environmentId in request

* new icon

---------

Co-authored-by: moritzrengert <moritz@rengert.de>
This commit is contained in:
Johannes
2023-04-11 10:42:33 +02:00
committed by GitHub
parent edc149417a
commit 357628b0d6
5 changed files with 184 additions and 5 deletions

View File

@@ -0,0 +1,70 @@
"use client";
import Modal from "@/components/shared/Modal";
import { createProduct } from "@/lib/products/products";
import { Button, Input, Label } from "@formbricks/ui";
import { PlusCircleIcon } from "@heroicons/react/24/outline";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
interface AddProductModalProps {
environmentId: string;
open: boolean;
setOpen: (v: boolean) => void;
}
export default function AddProductModal({ environmentId, open, setOpen }: AddProductModalProps) {
const router = useRouter();
const { register, handleSubmit } = useForm();
const submitProduct = async (data) => {
const newEnv = await createProduct(environmentId, data);
router.push(`/environments/${newEnv.id}/`);
setOpen(false);
};
return (
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-10 w-10 text-slate-500">
<PlusCircleIcon />
</div>
<div>
<div className="text-xl font-medium text-slate-700">Add Product</div>
<div className="text-sm text-slate-500">Create a new product for your team.</div>
</div>
</div>
</div>
</div>
<form onSubmit={handleSubmit(submitProduct)}>
<div className="flex w-full justify-between space-y-4 rounded-lg p-6">
<div className="grid w-full gap-x-2">
<div>
<Label>Name</Label>
<Input placeholder="e.g. My New Product" {...register("name", { required: true })} />
</div>
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
<Button
type="button"
variant="minimal"
onClick={() => {
setOpen(false);
}}>
Cancel
</Button>
<Button variant="primary" type="submit">
Add product
</Button>
</div>
</div>
</form>
</div>
</Modal>
);
}

View File

@@ -46,6 +46,7 @@ import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import AddProductModal from "./AddProductModal";
interface EnvironmentsNavbarProps {
environmentId: string;
@@ -59,6 +60,8 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
const [widgetSetupCompleted, setWidgetSetupCompleted] = useState(false);
const [showAddProductModal, setShowAddProductModal] = useState(false);
useEffect(() => {
if (environment && environment.widgetSetupCompleted) {
setWidgetSetupCompleted(true);
@@ -171,6 +174,12 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
router.push(`/environments/${newEnvironmentId}/`);
};
const changeEnvironmentByProduct = (productId: string) => {
const product = environment.availableProducts.find((p) => p.id === productId);
const newEnvironmentId = product?.environments[0]?.id;
router.push(`/environments/${newEnvironmentId}/`);
};
if (isLoadingEnvironment) {
return <LoadingSpinner />;
}
@@ -250,12 +259,21 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem>
<span>{environment?.product?.name}</span>
</DropdownMenuItem>
<DropdownMenuRadioGroup
value={environment?.product.id}
onValueChange={changeEnvironmentByProduct}>
{environment?.availableProducts?.map((product) => (
<DropdownMenuRadioItem
value={product.id}
className="cursor-pointer"
key={product.id}>
{product.name}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem disabled>
<DropdownMenuItem onClick={() => setShowAddProductModal(true)}>
<PlusIcon className="mr-2 h-4 w-4" />
<span>Add product</span>
</DropdownMenuItem>
@@ -327,6 +345,11 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
</div>
</div>
</div>
<AddProductModal
open={showAddProductModal}
setOpen={(val) => setShowAddProductModal(val)}
environmentId={environmentId}
/>
</nav>
);
}

View File

@@ -15,3 +15,15 @@ export const useProduct = (environmentId: string) => {
mutateProduct: mutate,
};
};
export const createProduct = async (environmentId, product: { name: string }) => {
const response = await fetch(`/api/v1/environments/${environmentId}/product`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(product),
});
return response.json();
};

View File

@@ -19,6 +19,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
include: {
product: {
select: {
id: true,
name: true,
teamId: true,
brandColor: true,
@@ -27,10 +28,31 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
},
},
});
if (environment === null) {
return res.status(404).json({ message: "This environment doesn't exist" });
}
return res.json(environment);
const products = await prisma.product.findMany({
where: {
teamId: environment.product.teamId,
},
select: {
id: true,
name: true,
brandColor: true,
environments: {
where: {
type: "production",
},
select: {
id: true,
},
},
},
});
return res.json({ ...environment, availableProducts: products });
}
if (req.method === "PUT") {

View File

@@ -1,5 +1,7 @@
import { hasEnvironmentAccess } from "@/lib/api/apiHelper";
import { prisma } from "@formbricks/database";
import { EnvironmentType } from "@prisma/client";
import { populateEnvironment } from "@/lib/populate";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
@@ -63,6 +65,56 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
return res.json(prismaRes);
}
// POST
else if (req.method === "POST") {
const { name } = req.body;
// Get the teamId of the current environment
const environment = await prisma.environment.findUnique({
where: { id: environmentId },
select: {
product: {
select: {
teamId: true,
},
},
},
});
if (!environment) {
res.status(404).json({ error: "Environment not found" });
return;
}
// Create a new product and associate it with the current team
const newProduct = await prisma.product.create({
data: {
name,
team: {
connect: { id: environment.product.teamId },
},
environments: {
create: [
{
type: EnvironmentType.production,
...populateEnvironment,
},
{
type: EnvironmentType.development,
...populateEnvironment,
},
],
},
},
select: {
environments: true,
},
});
const firstEnvironment = newProduct.environments[0];
res.json(firstEnvironment);
}
// Unknown HTTP Method
else {
throw new Error(`The HTTP ${req.method} method is not supported by this route.`);