mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-07 00:40:10 -06:00
refactor: move product settings page to server components (#679)
* feat: moves edit product name to server component and actions * feat: moves edit waiting time component to server * feat: moves product settings to server components * feat: moves delete product to server components * fix: fixes delete product * fix: server fixes * fix: fixes loading state * fix: fixes type * fix: fixes membership types * fix: fixes * fix: changes and fixes * fix build errors * remove duplicate membership types --------- Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
@@ -9,6 +9,9 @@ import { getEnvironment } from "@formbricks/lib/services/environment";
|
||||
|
||||
export default async function ProfileSettingsPage({ params }) {
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<SettingsTitle title="API Keys" />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants";
|
||||
import { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product";
|
||||
import { TProduct } from "@formbricks/types/v1/product";
|
||||
import { Button, ColorPicker, Label, Switch } from "@formbricks/ui";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -20,10 +20,7 @@ export const EditHighlightBorder = ({ product }: EditHighlightBorderProps) => {
|
||||
const handleUpdateHighlightBorder = async () => {
|
||||
try {
|
||||
setUpdatingBorder(true);
|
||||
let inputProduct: Partial<TProductUpdateInput> = {
|
||||
highlightBorderColor: color,
|
||||
};
|
||||
await updateProductAction(product.id, inputProduct);
|
||||
await updateProductAction(product.id, { highlightBorderColor: color });
|
||||
toast.success("Border color updated successfully.");
|
||||
} catch (error) {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
@@ -46,54 +43,6 @@ export const EditHighlightBorder = ({ product }: EditHighlightBorderProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
/* return (
|
||||
<div className="flex min-h-full w-full">
|
||||
<div className="flex w-1/2 flex-col px-6 py-5">
|
||||
<div className="mb-6 flex items-center space-x-2">
|
||||
<Switch id="highlightBorder" checked={showHighlightBorder} onCheckedChange={handleSwitch} />
|
||||
<h2 className="text-sm font-medium text-slate-800">Show highlight border</h2>
|
||||
</div>
|
||||
|
||||
{showHighlightBorder && color ? (
|
||||
<>
|
||||
<Label htmlFor="brandcolor">Color (HEX)</Label>
|
||||
<ColorPicker color={color} onChange={setColor} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
className="mt-4 flex max-w-[80px] items-center justify-center"
|
||||
loading={updatingBorder}
|
||||
onClick={handleUpdateHighlightBorder}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex w-1/2 flex-col items-center justify-center gap-4 bg-slate-200 px-6 py-5">
|
||||
<h3 className="text-slate-500">Preview</h3>
|
||||
<div
|
||||
className={cn("flex flex-col gap-4 rounded-lg border-2 bg-white p-5")}
|
||||
{...(showHighlightBorder &&
|
||||
color && {
|
||||
style: {
|
||||
borderColor: color,
|
||||
},
|
||||
})}>
|
||||
<h3 className="text-sm font-semibold text-slate-800">How easy was it for you to do this?</h3>
|
||||
<div className="flex rounded-2xl border border-slate-400">
|
||||
{[1, 2, 3, 4, 5].map((num) => (
|
||||
<div key={num} className="border-r border-slate-400 px-6 py-5 last:border-r-0">
|
||||
<span className="text-sm font-medium">{num}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}; */
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full w-full flex-col md:flex-row">
|
||||
<div className="flex w-full flex-col px-6 py-5 md:w-1/2">
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { getProducts } from "@formbricks/lib/services/product";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/services/team";
|
||||
import { TProduct } from "@formbricks/types/v1/product";
|
||||
import DeleteProductRender from "@/app/(app)/environments/[environmentId]/settings/product/DeleteProductRender";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
|
||||
type DeleteProductProps = {
|
||||
environmentId: string;
|
||||
product: TProduct;
|
||||
};
|
||||
|
||||
export default async function DeleteProduct({ environmentId, product }: DeleteProductProps) {
|
||||
const session = await getServerSession(authOptions);
|
||||
const team = await getTeamByEnvironmentId(environmentId);
|
||||
const availableProducts = team ? await getProducts(team.id) : null;
|
||||
|
||||
const role = team ? session?.user.teams.find((foundTeam) => foundTeam.id === team.id)?.role : null;
|
||||
const availableProductsLength = availableProducts ? availableProducts.length : 0;
|
||||
const isUserAdminOrOwner = role === "admin" || role === "owner";
|
||||
const isDeleteDisabled = availableProductsLength <= 1 || !isUserAdminOrOwner;
|
||||
|
||||
return (
|
||||
<DeleteProductRender
|
||||
isDeleteDisabled={isDeleteDisabled}
|
||||
isUserAdminOrOwner={isUserAdminOrOwner}
|
||||
product={product}
|
||||
environmentId={environmentId}
|
||||
userId={session?.user.id ?? ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { deleteProductAction } from "@/app/(app)/environments/[environmentId]/settings/product/actions";
|
||||
import DeleteDialog from "@/components/shared/DeleteDialog";
|
||||
import { truncate } from "@/lib/utils";
|
||||
import { TProduct } from "@formbricks/types/v1/product";
|
||||
import { Button } from "@formbricks/ui";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
type DeleteProductRenderProps = {
|
||||
environmentId: string;
|
||||
isDeleteDisabled: boolean;
|
||||
isUserAdminOrOwner: boolean;
|
||||
product: TProduct;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
const DeleteProductRender: React.FC<DeleteProductRenderProps> = ({
|
||||
environmentId,
|
||||
isDeleteDisabled,
|
||||
isUserAdminOrOwner,
|
||||
product,
|
||||
userId,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
|
||||
const handleDeleteProduct = async () => {
|
||||
try {
|
||||
const deletedProduct = await deleteProductAction(environmentId, userId, product.id);
|
||||
|
||||
if (!!deletedProduct?.id) {
|
||||
toast.success("Product deleted successfully.");
|
||||
router.push("/");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("Could not delete product.");
|
||||
setIsDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!isDeleteDisabled && (
|
||||
<div>
|
||||
<p className="text-sm text-slate-900">
|
||||
Delete {truncate(product.name, 30)}
|
||||
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-grey-500 ring-1 ring-offset-1" : ""}`}
|
||||
onClick={() => setIsDeleteDialogOpen(true)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDeleteDisabled && (
|
||||
<p className="text-sm text-red-700">
|
||||
{!isUserAdminOrOwner
|
||||
? "Only Admin or Owners can delete products."
|
||||
: "This is your only product, it cannot be deleted. Create a new product first."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DeleteDialog
|
||||
deleteWhat="Product"
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setIsDeleteDialogOpen}
|
||||
onDelete={handleDeleteProduct}
|
||||
text={`Are you sure you want to delete "${truncate(
|
||||
product.name,
|
||||
30
|
||||
)}"? This action cannot be undone.`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteProductRender;
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { updateProductAction } from "./actions";
|
||||
import { TProduct } from "@formbricks/types/v1/product";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { Button, Input, Label } from "@formbricks/ui";
|
||||
|
||||
type TEditProductName = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
type EditProductNameProps = {
|
||||
product: TProduct;
|
||||
environmentId: string;
|
||||
};
|
||||
|
||||
const EditProductName: React.FC<EditProductNameProps> = ({ product, environmentId }) => {
|
||||
const router = useRouter();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<TEditProductName>({
|
||||
defaultValues: {
|
||||
name: product.name,
|
||||
},
|
||||
});
|
||||
|
||||
const updateProduct: SubmitHandler<TEditProductName> = async (data) => {
|
||||
try {
|
||||
await updateProductAction(environmentId, product.id, data);
|
||||
toast.success("Product name updated successfully.");
|
||||
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
toast.error(`Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="w-full max-w-sm items-center" onSubmit={handleSubmit(updateProduct)}>
|
||||
<Label htmlFor="fullname">What's your product called?</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="fullname"
|
||||
defaultValue={product.name}
|
||||
{...register("name", { required: { value: true, message: "Product name can't be empty" } })}
|
||||
/>
|
||||
|
||||
{errors?.name ? (
|
||||
<div className="my-2">
|
||||
<p className="text-xs text-red-500">{errors?.name?.message}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" variant="darkCTA" className="mt-4">
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditProductName;
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Button, Input, Label } from "@formbricks/ui";
|
||||
import { TProduct } from "@formbricks/types/v1/product";
|
||||
import { updateProductAction } from "./actions";
|
||||
|
||||
type EditWaitingTimeFormValues = {
|
||||
recontactDays: number;
|
||||
};
|
||||
|
||||
type EditWaitingTimeProps = {
|
||||
environmentId: string;
|
||||
product: TProduct;
|
||||
};
|
||||
|
||||
const EditWaitingTime: React.FC<EditWaitingTimeProps> = ({ product, environmentId }) => {
|
||||
const router = useRouter();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<EditWaitingTimeFormValues>({
|
||||
defaultValues: {
|
||||
recontactDays: product.recontactDays,
|
||||
},
|
||||
});
|
||||
|
||||
const updateWaitingTime: SubmitHandler<EditWaitingTimeFormValues> = async (data) => {
|
||||
try {
|
||||
await updateProductAction(environmentId, product.id, data);
|
||||
toast.success("Waiting period updated successfully.");
|
||||
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
toast.error(`Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="w-full max-w-sm items-center" onSubmit={handleSubmit(updateWaitingTime)}>
|
||||
<Label htmlFor="recontactDays">Wait X days before showing next survey:</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id="recontactDays"
|
||||
defaultValue={product.recontactDays}
|
||||
{...register("recontactDays", {
|
||||
min: { value: 0, message: "Must be a positive number" },
|
||||
max: { value: 365, message: "Must be less than 365" },
|
||||
valueAsNumber: true,
|
||||
required: {
|
||||
value: true,
|
||||
message: "Required",
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
||||
{errors?.recontactDays ? (
|
||||
<div className="my-2">
|
||||
<p className="text-xs text-red-500">{errors?.recontactDays?.message}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" variant="darkCTA" className="mt-4">
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditWaitingTime;
|
||||
@@ -0,0 +1,84 @@
|
||||
"use server";
|
||||
|
||||
import { deleteProduct, getProducts, updateProduct } from "@formbricks/lib/services/product";
|
||||
import { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
|
||||
import { getEnvironment } from "@formbricks/lib/services/environment";
|
||||
import { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/api/apiHelper";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/services/team";
|
||||
import { getMembershipByUserId } from "@formbricks/lib/services/membership";
|
||||
|
||||
export const updateProductAction = async (
|
||||
environmentId: string,
|
||||
productId: string,
|
||||
data: Partial<TProductUpdateInput>
|
||||
): Promise<TProduct> => {
|
||||
const session = await getServerSession();
|
||||
|
||||
if (!session?.user) {
|
||||
throw new AuthenticationError("Not authenticated");
|
||||
}
|
||||
|
||||
// get the environment from service and check if the user is allowed to update the product
|
||||
let environment: TEnvironment | null = null;
|
||||
|
||||
try {
|
||||
environment = await getEnvironment(environmentId);
|
||||
|
||||
if (!environment) {
|
||||
throw new ResourceNotFoundError("Environment", "Environment not found");
|
||||
}
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!hasUserEnvironmentAccess(session.user, environment.id)) {
|
||||
throw new AuthenticationError("You don't have access to this environment");
|
||||
}
|
||||
|
||||
const updatedProduct = await updateProduct(productId, data);
|
||||
return updatedProduct;
|
||||
};
|
||||
|
||||
export const deleteProductAction = async (environmentId: string, userId: string, productId: string) => {
|
||||
const session = await getServerSession();
|
||||
|
||||
if (!session?.user) {
|
||||
throw new AuthenticationError("Not authenticated");
|
||||
}
|
||||
|
||||
// get the environment from service and check if the user is allowed to update the product
|
||||
let environment: TEnvironment | null = null;
|
||||
|
||||
try {
|
||||
environment = await getEnvironment(environmentId);
|
||||
|
||||
if (!environment) {
|
||||
throw new ResourceNotFoundError("Environment", "Environment not found");
|
||||
}
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!hasUserEnvironmentAccess(session.user, environment.id)) {
|
||||
throw new AuthenticationError("You don't have access to this environment");
|
||||
}
|
||||
|
||||
const team = await getTeamByEnvironmentId(environmentId);
|
||||
const membership = team ? await getMembershipByUserId(userId, team.id) : null;
|
||||
|
||||
if (membership?.role !== "admin" && membership?.role !== "owner") {
|
||||
throw new AuthenticationError("You are not allowed to delete products.");
|
||||
}
|
||||
|
||||
const availableProducts = team ? await getProducts(team.id) : null;
|
||||
|
||||
if (!!availableProducts && availableProducts?.length <= 1) {
|
||||
throw new Error("You can't delete the last product in the environment.");
|
||||
}
|
||||
|
||||
const deletedProduct = await deleteProduct(productId);
|
||||
return deletedProduct;
|
||||
};
|
||||
@@ -1,206 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm, useWatch } 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);
|
||||
const { isMutatingProduct, triggerProductMutate } = useProductMutation(environmentId);
|
||||
const { mutateEnvironment } = useEnvironment(environmentId);
|
||||
|
||||
const { register, handleSubmit, control, setValue } = useForm();
|
||||
|
||||
const productName = useWatch({
|
||||
control,
|
||||
name: "name",
|
||||
});
|
||||
const isProductNameInputEmpty = !productName?.trim();
|
||||
const currentProductName = productName?.trim().toLowerCase() ?? "";
|
||||
const previousProductName = product?.name?.trim().toLowerCase() ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
setValue("name", product?.name ?? "");
|
||||
}, [product?.name]);
|
||||
|
||||
if (isLoadingProduct) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
if (isErrorProduct) {
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="w-full max-w-sm items-center"
|
||||
onSubmit={handleSubmit((data) => {
|
||||
triggerProductMutate(data)
|
||||
.then(() => {
|
||||
toast.success("Product name updated successfully.");
|
||||
mutateEnvironment();
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
});
|
||||
})}>
|
||||
<Label htmlFor="fullname">What's your product called?</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="fullname"
|
||||
defaultValue={product.name}
|
||||
{...register("name")}
|
||||
className={isProductNameInputEmpty ? "border-red-300 focus:border-red-300" : ""}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="darkCTA"
|
||||
className="mt-4"
|
||||
loading={isMutatingProduct}
|
||||
disabled={isProductNameInputEmpty || currentProductName === previousProductName}>
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditWaitingTime({ environmentId }) {
|
||||
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
|
||||
const { isMutatingProduct, triggerProductMutate } = useProductMutation(environmentId);
|
||||
|
||||
const { register, handleSubmit } = useForm();
|
||||
|
||||
if (isLoadingProduct) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
if (isErrorProduct) {
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="w-full max-w-sm items-center"
|
||||
onSubmit={handleSubmit((data) => {
|
||||
triggerProductMutate(data)
|
||||
.then(() => {
|
||||
toast.success("Waiting period updated successfully.");
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
});
|
||||
})}>
|
||||
<Label htmlFor="recontactDays">Wait X days before showing next survey:</Label>
|
||||
<Input
|
||||
type="number"
|
||||
id="recontactDays"
|
||||
defaultValue={product.recontactDays}
|
||||
{...register("recontactDays", {
|
||||
min: 0,
|
||||
max: 365,
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
|
||||
<Button type="submit" variant="darkCTA" className="mt-4" loading={isMutatingProduct}>
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeleteProduct({ environmentId }) {
|
||||
const router = useRouter();
|
||||
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [deletingProduct, setDeletingProduct] = 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. Your team needs at least 1.");
|
||||
setIsDeleteDialogOpen(false);
|
||||
return;
|
||||
}
|
||||
setDeletingProduct(true);
|
||||
const deleteProductRes = await deleteProduct(environmentId);
|
||||
setDeletingProduct(false);
|
||||
|
||||
if (deleteProductRes?.id?.length > 0) {
|
||||
toast.success("Product deleted successfully.");
|
||||
router.push("/");
|
||||
} else if (deleteProductRes?.message?.length > 0) {
|
||||
toast.error(deleteProductRes.message);
|
||||
setIsDeleteDialogOpen(false);
|
||||
} else {
|
||||
toast.error("Error deleting product. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!isDeleteDisabled && (
|
||||
<div>
|
||||
<p className="text-sm text-slate-900">
|
||||
Delete {truncate(product?.name, 30)}
|
||||
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-grey-500 ring-1 ring-offset-1" : ""}`}
|
||||
onClick={() => setIsDeleteDialogOpen(true)}>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isDeleteDisabled && (
|
||||
<p className="text-sm text-red-700">
|
||||
{!isUserAdminOrOwner
|
||||
? "Only Admin or Owners can delete products."
|
||||
: "This is your only product, it cannot be deleted. Create a new product first."}
|
||||
</p>
|
||||
)}
|
||||
<DeleteDialog
|
||||
deleteWhat="Product"
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setIsDeleteDialogOpen}
|
||||
isDeleting={deletingProduct}
|
||||
onDelete={handleDeleteProduct}
|
||||
text={`Are you sure you want to delete "${truncate(
|
||||
product?.name,
|
||||
30
|
||||
)}"? This action cannot be undone.`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
function LoadingCard({ title, description, skeletonLines }) {
|
||||
return (
|
||||
<div className="my-4 rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-100 px-6 py-5 text-left text-slate-900">
|
||||
<h3 className="text-lg font-medium leading-6">{title}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">{description}</p>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="rounded-lg px-6 py-5 hover:bg-slate-100">
|
||||
{skeletonLines.map((line, index) => (
|
||||
<div key={index} className="mt-4">
|
||||
<div className={`animate-pulse rounded-full bg-gray-200 ${line.classes}`}></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Loading() {
|
||||
const cards = [
|
||||
{
|
||||
title: "Product Name",
|
||||
description: "Change your products name.",
|
||||
skeletonLines: [{ classes: "h-4 w-28" }, { classes: "h-6 w-64" }, { classes: "h-8 w-24" }],
|
||||
},
|
||||
{
|
||||
title: "Recontact Waiting Time",
|
||||
description: "Control how frequently users can be surveyed across all surveys.",
|
||||
skeletonLines: [{ classes: "h-4 w-28" }, { classes: "h-6 w-64" }, { classes: "h-8 w-24" }],
|
||||
},
|
||||
{
|
||||
title: "Delete Product",
|
||||
description:
|
||||
"Delete product with all surveys, responses, people, actions and attributes. This cannot be undone.",
|
||||
skeletonLines: [{ classes: "h-4 w-96" }, { classes: "h-8 w-24" }],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">Product Settings</h2>
|
||||
{cards.map((card, index) => (
|
||||
<LoadingCard key={index} {...card} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,38 @@
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
|
||||
|
||||
import SettingsCard from "../SettingsCard";
|
||||
import SettingsTitle from "../SettingsTitle";
|
||||
import { EditProductName, EditWaitingTime, DeleteProduct } from "./editProduct";
|
||||
|
||||
export default function ProfileSettingsPage({ params }) {
|
||||
import EditProductName from "./EditProductName";
|
||||
import EditWaitingTime from "./EditWaitingTime";
|
||||
import DeleteProduct from "./DeleteProduct";
|
||||
import { getEnvironment } from "@formbricks/lib/services/environment";
|
||||
|
||||
export default async function ProfileSettingsPage({ params }: { params: { environmentId: string } }) {
|
||||
const [, product] = await Promise.all([
|
||||
getEnvironment(params.environmentId),
|
||||
getProductByEnvironmentId(params.environmentId),
|
||||
]);
|
||||
|
||||
if (!product) {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsTitle title="Product Settings" />
|
||||
<SettingsCard title="Product Name" description="Change your products name.">
|
||||
<EditProductName environmentId={params.environmentId} />
|
||||
<EditProductName environmentId={params.environmentId} product={product} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title="Recontact Waiting Time"
|
||||
description="Control how frequently users can be surveyed across all surveys.">
|
||||
<EditWaitingTime environmentId={params.environmentId} />
|
||||
<EditWaitingTime environmentId={params.environmentId} product={product} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title="Delete Product"
|
||||
description="Delete product with all surveys, responses, people, actions and attributes. This cannot be undone.">
|
||||
<DeleteProduct environmentId={params.environmentId} />
|
||||
<DeleteProduct environmentId={params.environmentId} product={product} />
|
||||
</SettingsCard>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,9 @@ export default async function SurveysList({ environmentId }: { environmentId: st
|
||||
}
|
||||
|
||||
const environment = await getEnvironment(environmentId);
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
const surveys: TSurveyWithAnalytics[] = await getSurveysWithAnalytics(environmentId);
|
||||
const environments: TEnvironment[] = await getEnvironments(product.id);
|
||||
const otherEnvironment = environments.find((e) => e.type !== environment.type)!;
|
||||
|
||||
@@ -11,6 +11,10 @@ export default async function SurveyTemplatesPage({ params }) {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
|
||||
return (
|
||||
<TemplateContainerWithPreview environmentId={environmentId} environment={environment} product={product} />
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ZEnvironment, ZEnvironmentUpdateInput, ZId } from "@formbricks/types/v1
|
||||
import { cache } from "react";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
export const getEnvironment = cache(async (environmentId: string): Promise<TEnvironment> => {
|
||||
export const getEnvironment = cache(async (environmentId: string): Promise<TEnvironment | null> => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
let environmentPrisma;
|
||||
try {
|
||||
@@ -19,9 +19,7 @@ export const getEnvironment = cache(async (environmentId: string): Promise<TEnvi
|
||||
},
|
||||
});
|
||||
|
||||
if (!environmentPrisma) {
|
||||
throw new ResourceNotFoundError("Environment", environmentId);
|
||||
}
|
||||
return environmentPrisma;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
|
||||
20
packages/lib/services/membership.ts
Normal file
20
packages/lib/services/membership.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TMembership } from "@formbricks/types/v1/memberships";
|
||||
import { cache } from "react";
|
||||
|
||||
export const getMembershipByUserId = cache(
|
||||
async (userId: string, teamId: string): Promise<TMembership | null> => {
|
||||
const membership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) return null;
|
||||
|
||||
return membership;
|
||||
}
|
||||
);
|
||||
@@ -49,6 +49,7 @@ export const getProductByEnvironmentId = cache(async (environmentId: string): Pr
|
||||
throw new ValidationError("EnvironmentId is required");
|
||||
}
|
||||
let productPrisma;
|
||||
|
||||
try {
|
||||
productPrisma = await prisma.product.findFirst({
|
||||
where: {
|
||||
@@ -90,7 +91,6 @@ export const updateProduct = async (
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const product = ZProduct.parse(updatedProduct);
|
||||
@@ -121,3 +121,13 @@ export const getProduct = cache(async (productId: string): Promise<TProduct | nu
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteProduct = cache(async (productId: string): Promise<TProduct> => {
|
||||
const product = await prisma.product.delete({
|
||||
where: {
|
||||
id: productId,
|
||||
},
|
||||
});
|
||||
|
||||
return product;
|
||||
});
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/v1/environment";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { TProfile, ZProfileUpdateInput } from "@formbricks/types/v1/profile";
|
||||
import { deleteTeam } from "./team";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { TMembership, TMembershipRole, ZMembershipRole } from "@formbricks/types/v1/memberships";
|
||||
import { TProfile, TProfileUpdateInput, ZProfileUpdateInput } from "@formbricks/types/v1/profile";
|
||||
import { MembershipRole, Prisma } from "@prisma/client";
|
||||
import { cache } from "react";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { ZId } from "@formbricks/types/v1/environment";
|
||||
import { TMembership, TMembershipRole, ZMembershipRole } from "@formbricks/types/v1/membership";
|
||||
import { TProfileUpdateInput } from "@formbricks/types/v1/profile";
|
||||
import { deleteTeam } from "./team";
|
||||
|
||||
const responseSelection = {
|
||||
id: true,
|
||||
@@ -59,7 +57,7 @@ const updateUserMembership = async (teamId: string, userId: string, role: TMembe
|
||||
});
|
||||
};
|
||||
|
||||
const getAdminMemberships = (memberships: TMembership[]) =>
|
||||
const getAdminMemberships = (memberships: TMembership[]): TMembership[] =>
|
||||
memberships.filter((membership) => membership.role === MembershipRole.admin);
|
||||
|
||||
// function to update a user's profile
|
||||
@@ -107,12 +105,7 @@ export const deleteProfile = async (personId: string): Promise<void> => {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
memberships: {
|
||||
select: {
|
||||
userId: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
memberships: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { z } from "zod";
|
||||
import z from "zod";
|
||||
|
||||
export const ZMembershipRole = z.enum(["owner", "admin", "editor", "developer", "viewer"]);
|
||||
|
||||
export type TMembershipRole = z.infer<typeof ZMembershipRole>;
|
||||
|
||||
export const ZMembership = z.object({
|
||||
role: ZMembershipRole,
|
||||
teamId: z.string(),
|
||||
userId: z.string(),
|
||||
accepted: z.boolean(),
|
||||
role: ZMembershipRole,
|
||||
});
|
||||
|
||||
export type TMembership = z.infer<typeof ZMembership>;
|
||||
export type TMembershipRole = z.infer<typeof ZMembershipRole>;
|
||||
Reference in New Issue
Block a user