mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-21 13:40:31 -06:00
Compare commits
10 Commits
fix-survey
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
474ae4478f | ||
|
|
a01107521e | ||
|
|
2e17e70c00 | ||
|
|
641b597ddc | ||
|
|
b6986e5470 | ||
|
|
82a5eed6e5 | ||
|
|
0f20a4460f | ||
|
|
95ae35a3b5 | ||
|
|
ddd91607b1 | ||
|
|
325eeb10ef |
@@ -9,9 +9,30 @@ import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service"
|
||||
import { deleteProduct, getProducts, updateProduct } from "@formbricks/lib/product/service";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { Result, ok } from "@formbricks/types/errorHandlers";
|
||||
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TProduct, TProductUpdateInput } from "@formbricks/types/product";
|
||||
|
||||
interface State {
|
||||
params: { environmentId: string; productId: string };
|
||||
response?: Result<TProduct>;
|
||||
}
|
||||
|
||||
export const updateProductFormAction = async (state: State, data: FormData): Promise<State> => {
|
||||
console.log({ state });
|
||||
const formData = Object.fromEntries(data);
|
||||
console.log({ formData });
|
||||
|
||||
const updatedProduct = await updateProductAction(state.params.environmentId, state.params.productId, {
|
||||
name: formData.name as string,
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
response: ok(updatedProduct),
|
||||
};
|
||||
};
|
||||
|
||||
export const updateProductAction = async (
|
||||
environmentId: string,
|
||||
productId: string,
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
|
||||
import { updateProductAction } from "../actions";
|
||||
|
||||
type TEditProductName = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
type EditProductNameProps = {
|
||||
product: TProduct;
|
||||
environmentId: string;
|
||||
isProductNameEditDisabled: boolean;
|
||||
};
|
||||
|
||||
export const EditProductName: React.FC<EditProductNameProps> = ({
|
||||
product,
|
||||
environmentId,
|
||||
isProductNameEditDisabled,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
watch,
|
||||
} = useForm<TEditProductName>({
|
||||
defaultValues: {
|
||||
name: product.name,
|
||||
},
|
||||
});
|
||||
const productNameValue = watch("name", product.name || "");
|
||||
const isNotEmptySpaces = (value: string) => value.trim() !== "";
|
||||
|
||||
const updateProduct: SubmitHandler<TEditProductName> = async (data) => {
|
||||
data.name = data.name.trim();
|
||||
try {
|
||||
if (!isNotEmptySpaces(data.name)) {
|
||||
toast.error("Please enter at least one character");
|
||||
return;
|
||||
}
|
||||
if (data.name === product.name) {
|
||||
toast.success("This is already your product name");
|
||||
return;
|
||||
}
|
||||
const updatedProduct = await updateProductAction(environmentId, product.id, { name: data.name });
|
||||
if (isProductNameEditDisabled) {
|
||||
toast.error("Only Owners, Admins and Editors can perform this action.");
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
if (!!updatedProduct?.id) {
|
||||
toast.success("Product name updated successfully.");
|
||||
router.refresh();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error(`Error: Unable to save product information`);
|
||||
}
|
||||
};
|
||||
|
||||
return !isProductNameEditDisabled ? (
|
||||
<form className="w-full max-w-sm items-center space-y-2" onSubmit={handleSubmit(updateProduct)}>
|
||||
<Label htmlFor="fullname">What's your product called?</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="fullname"
|
||||
defaultValue={product.name}
|
||||
{...register("name", { required: true })}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="darkCTA"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
disabled={!isNotEmptySpaces(productNameValue) || isSubmitting}>
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="text-sm text-red-700">Only Owners, Admins and Editors can perform this action.</p>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRef } from "react";
|
||||
import { useFormState } from "react-dom";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TProduct, ZProduct } from "@formbricks/types/product";
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage, FormProvider } from "@formbricks/ui/Form";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
|
||||
import { updateProductAction, updateProductFormAction } from "../actions";
|
||||
import { SubmitButton } from "./SubmitBtn";
|
||||
|
||||
type EditProductNameProps = {
|
||||
product: TProduct;
|
||||
environmentId: string;
|
||||
isProductNameEditDisabled: boolean;
|
||||
};
|
||||
|
||||
const ZProductNameInput = ZProduct.pick({ name: true });
|
||||
|
||||
type TEditProductName = z.infer<typeof ZProductNameInput>;
|
||||
|
||||
export const EditProductNameForm: React.FC<EditProductNameProps> = ({
|
||||
product,
|
||||
environmentId,
|
||||
isProductNameEditDisabled,
|
||||
}) => {
|
||||
const form = useForm<TEditProductName>({
|
||||
defaultValues: {
|
||||
name: product.name,
|
||||
},
|
||||
resolver: zodResolver(ZProductNameInput),
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
const [serverState, formAction] = useFormState(updateProductFormAction, {
|
||||
params: { environmentId, productId: product.id },
|
||||
});
|
||||
|
||||
const { errors, isDirty } = form.formState;
|
||||
|
||||
const nameError = errors.name?.message;
|
||||
// const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
// const updateProduct: SubmitHandler<TEditProductName> = async (data) => {
|
||||
// const name = data.name.trim();
|
||||
// try {
|
||||
// if (nameError) {
|
||||
// toast.error(nameError);
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const updatedProduct = await updateProductAction(environmentId, product.id, { name });
|
||||
|
||||
// if (isProductNameEditDisabled) {
|
||||
// toast.error("Only Owners, Admins and Editors can perform this action.");
|
||||
// return;
|
||||
// }
|
||||
|
||||
// if (!!updatedProduct?.id) {
|
||||
// toast.success("Product name updated successfully.");
|
||||
// form.resetField("name", { defaultValue: updatedProduct.name });
|
||||
// }
|
||||
// } catch (err) {
|
||||
// console.error(err);
|
||||
// toast.error(`Error: Unable to save product information`);
|
||||
// }
|
||||
// };
|
||||
|
||||
return !isProductNameEditDisabled ? (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
ref={formRef}
|
||||
className="w-full max-w-sm items-center space-y-2"
|
||||
action={formAction}
|
||||
onSubmit={(e) =>
|
||||
form.handleSubmit(() => {
|
||||
e.preventDefault();
|
||||
formRef.current?.submit();
|
||||
})
|
||||
}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel htmlFor="name">What's your product called?</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
id="name"
|
||||
{...field}
|
||||
placeholder="Product Name"
|
||||
autoComplete="off"
|
||||
required
|
||||
isInvalid={!!nameError}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SubmitButton />
|
||||
</form>
|
||||
</FormProvider>
|
||||
) : (
|
||||
<p className="text-sm text-red-700">Only Owners, Admins and Editors can perform this action.</p>
|
||||
);
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
|
||||
import { updateProductAction } from "../actions";
|
||||
|
||||
type EditWaitingTimeFormValues = {
|
||||
recontactDays: number;
|
||||
};
|
||||
|
||||
type EditWaitingTimeProps = {
|
||||
environmentId: string;
|
||||
product: TProduct;
|
||||
};
|
||||
|
||||
export 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 {
|
||||
const updatedProduct = await updateProductAction(environmentId, product.id, data);
|
||||
if (!!updatedProduct?.id) {
|
||||
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 space-y-2" 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" size="sm">
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TProduct, ZProduct } from "@formbricks/types/product";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage, FormProvider } from "@formbricks/ui/Form";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
|
||||
import { updateProductAction } from "../actions";
|
||||
|
||||
type EditWaitingTimeProps = {
|
||||
environmentId: string;
|
||||
product: TProduct;
|
||||
};
|
||||
|
||||
const ZProductRecontactDaysInput = ZProduct.pick({ recontactDays: true });
|
||||
|
||||
type EditWaitingTimeFormValues = z.infer<typeof ZProductRecontactDaysInput>;
|
||||
|
||||
export const EditWaitingTimeForm: React.FC<EditWaitingTimeProps> = ({ product, environmentId }) => {
|
||||
const form = useForm<EditWaitingTimeFormValues>({
|
||||
defaultValues: {
|
||||
recontactDays: product.recontactDays,
|
||||
},
|
||||
resolver: zodResolver(ZProductRecontactDaysInput),
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const { isDirty, isSubmitting } = form.formState;
|
||||
|
||||
const updateWaitingTime: SubmitHandler<EditWaitingTimeFormValues> = async (data) => {
|
||||
try {
|
||||
const updatedProduct = await updateProductAction(environmentId, product.id, data);
|
||||
if (!!updatedProduct?.id) {
|
||||
toast.success("Waiting period updated successfully.");
|
||||
form.resetField("recontactDays", { defaultValue: updatedProduct.recontactDays });
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(`Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
className="flex w-full max-w-sm flex-col space-y-4"
|
||||
onSubmit={form.handleSubmit(updateWaitingTime)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="recontactDays"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel htmlFor="recontactDays">Wait X days before showing next survey:</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
id="recontactDays"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") {
|
||||
field.onChange("");
|
||||
}
|
||||
|
||||
field.onChange(parseInt(value, 10));
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="darkCTA"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || !isDirty}>
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useFormStatus } from "react-dom";
|
||||
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
|
||||
export const SubmitButton = () => {
|
||||
const formStatus = useFormStatus();
|
||||
|
||||
return (
|
||||
<Button type="submit" variant="darkCTA" size="sm" loading={formStatus.pending}>
|
||||
Update
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -15,8 +15,8 @@ import { SettingsId } from "@formbricks/ui/SettingsId";
|
||||
|
||||
import { SettingsCard } from "../../settings/components/SettingsCard";
|
||||
import { DeleteProduct } from "./components/DeleteProduct";
|
||||
import { EditProductName } from "./components/EditProductName";
|
||||
import { EditWaitingTime } from "./components/EditWaitingTime";
|
||||
import { EditProductNameForm } from "./components/EditProductNameForm";
|
||||
import { EditWaitingTimeForm } from "./components/EditWaitingTimeForm";
|
||||
|
||||
const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
const [, product, session, team] = await Promise.all([
|
||||
@@ -57,7 +57,7 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
</PageHeader>
|
||||
|
||||
<SettingsCard title="Product Name" description="Change your products name.">
|
||||
<EditProductName
|
||||
<EditProductNameForm
|
||||
environmentId={params.environmentId}
|
||||
product={product}
|
||||
isProductNameEditDisabled={isProductNameEditDisabled}
|
||||
@@ -66,7 +66,7 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
<SettingsCard
|
||||
title="Recontact Waiting Time"
|
||||
description="Control how frequently users can be surveyed across all surveys.">
|
||||
<EditWaitingTime environmentId={params.environmentId} product={product} />
|
||||
<EditWaitingTimeForm environmentId={params.environmentId} product={product} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title="Delete Product"
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TPlacement } from "@formbricks/types/common";
|
||||
import { TProduct, TProductUpdateInput } from "@formbricks/types/product";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { getPlacementStyle } from "@formbricks/ui/PreviewSurvey/lib/utils";
|
||||
import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup";
|
||||
|
||||
import { updateProductAction } from "../actions";
|
||||
|
||||
const placements = [
|
||||
{ name: "Bottom Right", value: "bottomRight", disabled: false },
|
||||
{ name: "Top Right", value: "topRight", disabled: false },
|
||||
{ name: "Top Left", value: "topLeft", disabled: false },
|
||||
{ name: "Bottom Left", value: "bottomLeft", disabled: false },
|
||||
{ name: "Centered Modal", value: "center", disabled: false },
|
||||
];
|
||||
|
||||
interface EditPlacementProps {
|
||||
product: TProduct;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const EditPlacement = ({ product }: EditPlacementProps) => {
|
||||
const [currentPlacement, setCurrentPlacement] = useState<TPlacement>(product.placement);
|
||||
const [overlay, setOverlay] = useState(product.darkOverlay ? "darkOverlay" : "lightOverlay");
|
||||
const [clickOutside, setClickOutside] = useState(product.clickOutsideClose ? "allow" : "disallow");
|
||||
const [updatingPlacement, setUpdatingPlacement] = useState(false);
|
||||
const overlayStyle =
|
||||
currentPlacement === "center" && overlay === "darkOverlay" ? "bg-gray-700/80" : "bg-slate-200";
|
||||
|
||||
const handleUpdatePlacement = async () => {
|
||||
try {
|
||||
setUpdatingPlacement(true);
|
||||
let inputProduct: Partial<TProductUpdateInput> = {
|
||||
placement: currentPlacement,
|
||||
darkOverlay: overlay === "darkOverlay",
|
||||
clickOutsideClose: clickOutside === "allow",
|
||||
};
|
||||
|
||||
await updateProductAction(product.id, inputProduct);
|
||||
|
||||
toast.success("Placement updated successfully.");
|
||||
} catch (error) {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
} finally {
|
||||
setUpdatingPlacement(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full items-center">
|
||||
<div className="flex">
|
||||
<RadioGroup onValueChange={(e) => setCurrentPlacement(e as TPlacement)} value={currentPlacement}>
|
||||
{placements.map((placement) => (
|
||||
<div key={placement.value} className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id={placement.value} value={placement.value} disabled={placement.disabled} />
|
||||
<Label htmlFor={placement.value} className="text-slate-900">
|
||||
{placement.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<div
|
||||
className={cn(
|
||||
clickOutside === "disallow" ? "cursor-not-allowed" : "",
|
||||
"relative ml-8 h-40 w-full rounded",
|
||||
overlayStyle
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute h-16 w-16 cursor-default rounded bg-slate-700",
|
||||
getPlacementStyle(currentPlacement)
|
||||
)}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentPlacement === "center" && (
|
||||
<>
|
||||
<div className="mt-6 space-y-2">
|
||||
<Label className="font-semibold">Centered modal overlay color</Label>
|
||||
<RadioGroup onValueChange={(e) => setOverlay(e)} value={overlay} className="flex space-x-4">
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="lightOverlay" value="lightOverlay" />
|
||||
<Label htmlFor="lightOverlay" className="text-slate-900">
|
||||
Light Overlay
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="darkOverlay" value="darkOverlay" />
|
||||
<Label htmlFor="darkOverlay" className="text-slate-900">
|
||||
Dark Overlay
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div className="mt-6 space-y-2">
|
||||
<Label className="font-semibold">Allow users to exit by clicking outside the study</Label>
|
||||
<RadioGroup
|
||||
onValueChange={(e) => setClickOutside(e)}
|
||||
value={clickOutside}
|
||||
className="flex space-x-4">
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="disallow" value="disallow" />
|
||||
<Label htmlFor="disallow" className="text-slate-900">
|
||||
Don't Allow
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="allow" value="allow" />
|
||||
<Label htmlFor="allow" className="text-slate-900">
|
||||
Allow
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
className="mt-4"
|
||||
size="sm"
|
||||
loading={updatingPlacement}
|
||||
onClick={handleUpdatePlacement}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { FormControl, FormField, FormItem, FormLabel, FormProvider } from "@formbricks/ui/Form";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { getPlacementStyle } from "@formbricks/ui/PreviewSurvey/lib/utils";
|
||||
import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup";
|
||||
|
||||
import { updateProductAction } from "../actions";
|
||||
|
||||
const placements = [
|
||||
{ name: "Bottom Right", value: "bottomRight", disabled: false },
|
||||
{ name: "Top Right", value: "topRight", disabled: false },
|
||||
{ name: "Top Left", value: "topLeft", disabled: false },
|
||||
{ name: "Bottom Left", value: "bottomLeft", disabled: false },
|
||||
{ name: "Centered Modal", value: "center", disabled: false },
|
||||
];
|
||||
|
||||
interface EditPlacementProps {
|
||||
product: TProduct;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
const ZProductPlacementInput = z.object({
|
||||
placement: z.enum(["bottomRight", "topRight", "topLeft", "bottomLeft", "center"]),
|
||||
darkOverlay: z.boolean(),
|
||||
clickOutsideClose: z.boolean(),
|
||||
});
|
||||
|
||||
type EditPlacementFormValues = z.infer<typeof ZProductPlacementInput>;
|
||||
|
||||
export const EditPlacementForm = ({ product }: EditPlacementProps) => {
|
||||
const form = useForm<EditPlacementFormValues>({
|
||||
defaultValues: {
|
||||
placement: product.placement,
|
||||
darkOverlay: product.darkOverlay ?? false,
|
||||
clickOutsideClose: product.clickOutsideClose ?? false,
|
||||
},
|
||||
resolver: zodResolver(ZProductPlacementInput),
|
||||
});
|
||||
|
||||
const currentPlacement = form.watch("placement");
|
||||
const darkOverlay = form.watch("darkOverlay");
|
||||
const clickOutsideClose = form.watch("clickOutsideClose");
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
const overlayStyle = currentPlacement === "center" && darkOverlay ? "bg-gray-700/80" : "bg-slate-200";
|
||||
|
||||
const onSubmit: SubmitHandler<EditPlacementFormValues> = async (data) => {
|
||||
try {
|
||||
await updateProductAction(product.id, {
|
||||
placement: data.placement,
|
||||
darkOverlay: data.darkOverlay,
|
||||
clickOutsideClose: data.clickOutsideClose,
|
||||
});
|
||||
|
||||
toast.success("Placement updated successfully.");
|
||||
} catch (error) {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form className="w-full items-center" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="flex">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="placement"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
className="h-full">
|
||||
{placements.map((placement) => (
|
||||
<div key={placement.value} className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem
|
||||
id={placement.value}
|
||||
value={placement.value}
|
||||
disabled={placement.disabled}
|
||||
checked={field.value === placement.value}
|
||||
/>
|
||||
<Label htmlFor={placement.value} className="text-slate-900">
|
||||
{placement.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
clickOutsideClose ? "" : "cursor-not-allowed",
|
||||
"relative ml-8 h-40 w-full rounded",
|
||||
overlayStyle
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute h-16 w-16 cursor-default rounded bg-slate-700",
|
||||
getPlacementStyle(currentPlacement)
|
||||
)}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentPlacement === "center" && (
|
||||
<>
|
||||
<div className="mt-6 space-y-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="darkOverlay"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="font-semibold">Centered modal overlay color</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value === "darkOverlay");
|
||||
}}
|
||||
className="flex space-x-4">
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="lightOverlay" value="lightOverlay" checked={!field.value} />
|
||||
<Label htmlFor="lightOverlay" className="text-slate-900">
|
||||
Light Overlay
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="darkOverlay" value="darkOverlay" checked={field.value} />
|
||||
<Label htmlFor="darkOverlay" className="text-slate-900">
|
||||
Dark Overlay
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 space-y-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clickOutsideClose"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="font-semibold">
|
||||
Allow users to exit by clicking outside the study
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value === "allow");
|
||||
}}
|
||||
className="flex space-x-4">
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="disallow" value="disallow" checked={!field.value} />
|
||||
<Label htmlFor="disallow" className="text-slate-900">
|
||||
Don't Allow
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="allow" value="allow" checked={field.value} />
|
||||
<Label htmlFor="allow" className="text-slate-900">
|
||||
Allow
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button variant="darkCTA" className="mt-4 w-fit" size="sm" loading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -19,7 +19,7 @@ import { PageHeader } from "@formbricks/ui/PageHeader";
|
||||
|
||||
import { SettingsCard } from "../../settings/components/SettingsCard";
|
||||
import { EditFormbricksBranding } from "./components/EditBranding";
|
||||
import { EditPlacement } from "./components/EditPlacement";
|
||||
import { EditPlacementForm } from "./components/EditPlacementForm";
|
||||
import { ThemeStyling } from "./components/ThemeStyling";
|
||||
|
||||
const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
@@ -77,7 +77,7 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
<SettingsCard
|
||||
title="In-app Survey Placement"
|
||||
description="Change where surveys will be shown in your web app.">
|
||||
<EditPlacement product={product} environmentId={params.environmentId} />
|
||||
<EditPlacementForm product={product} environmentId={params.environmentId} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title="Formbricks Branding"
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@formbricks/tailwind-config": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"@hookform/resolvers": "^3.4.2",
|
||||
"@json2csv/node": "^7.0.6",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.46.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.51.1",
|
||||
|
||||
@@ -41,10 +41,14 @@ export const ZProduct = z.object({
|
||||
id: z.string().cuid2(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
name: z.string(),
|
||||
name: z.string().trim().min(1, { message: "Product name cannot be empty" }),
|
||||
teamId: z.string(),
|
||||
styling: ZProductStyling,
|
||||
recontactDays: z.number().int(),
|
||||
recontactDays: z
|
||||
.number({ message: "Recontact days is required" })
|
||||
.int()
|
||||
.min(0, { message: "Must be a positive number" })
|
||||
.max(365, { message: "Must be less than 365" }),
|
||||
inAppSurveyBranding: z.boolean(),
|
||||
linkSurveyBranding: z.boolean(),
|
||||
placement: ZPlacement,
|
||||
|
||||
154
packages/ui/Form/index.tsx
Normal file
154
packages/ui/Form/index.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import * as React from "react";
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
|
||||
import { Label } from "../Label";
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName;
|
||||
};
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext);
|
||||
const itemContext = React.useContext(FormItemContext);
|
||||
const { getFieldState, formState } = useFormContext();
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState);
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>");
|
||||
}
|
||||
|
||||
const { id } = itemContext;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
};
|
||||
};
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
|
||||
|
||||
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
FormItem.displayName = "FormItem";
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField();
|
||||
|
||||
return <Label ref={ref} className={cn(error && "text-error", className)} htmlFor={formItemId} {...props} />;
|
||||
});
|
||||
FormLabel.displayName = "FormLabel";
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
FormControl.displayName = "FormControl";
|
||||
|
||||
const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField();
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
FormDescription.displayName = "FormDescription";
|
||||
|
||||
const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField();
|
||||
const body = error ? String(error?.message) : children;
|
||||
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p ref={ref} id={formMessageId} className={cn("text-error text-sm", className)} {...props}>
|
||||
{body}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
);
|
||||
FormMessage.displayName = "FormMessage";
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
FormProvider,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
};
|
||||
@@ -17,7 +17,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, isInv
|
||||
className={cn(
|
||||
"focus:border-brand-dark flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300",
|
||||
className,
|
||||
isInvalid && "border border-red-600 focus:border-red-600"
|
||||
isInvalid && "border-error focus:border-error border"
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
|
||||
11
pnpm-lock.yaml
generated
11
pnpm-lock.yaml
generated
@@ -333,6 +333,9 @@ importers:
|
||||
'@formbricks/ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/ui
|
||||
'@hookform/resolvers':
|
||||
specifier: ^3.4.2
|
||||
version: 3.4.2(react-hook-form@7.51.4)
|
||||
'@json2csv/node':
|
||||
specifier: ^7.0.6
|
||||
version: 7.0.6
|
||||
@@ -3429,6 +3432,14 @@ packages:
|
||||
tailwindcss: 3.4.3
|
||||
dev: false
|
||||
|
||||
/@hookform/resolvers@3.4.2(react-hook-form@7.51.4):
|
||||
resolution: {integrity: sha512-1m9uAVIO8wVf7VCDAGsuGA0t6Z3m6jVGAN50HkV9vYLl0yixKK/Z1lr01vaRvYCkIKGoy1noVRxMzQYb4y/j1Q==}
|
||||
peerDependencies:
|
||||
react-hook-form: ^7.0.0
|
||||
dependencies:
|
||||
react-hook-form: 7.51.4(react@18.3.1)
|
||||
dev: false
|
||||
|
||||
/@httptoolkit/websocket-stream@6.0.1:
|
||||
resolution: {integrity: sha512-A0NOZI+Glp3Xgcz6Na7i7o09+/+xm2m0UCU8gdtM2nIv6/cjLmhMZMqehSpTlgbx9omtLmV8LVqOskPEyWnmZQ==}
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user