Compare commits

...

10 Commits

Author SHA1 Message Date
pandeymangg
474ae4478f formssssS 2024-05-24 23:04:05 +05:30
pandeymangg
a01107521e fix: FormProvider 2024-05-24 15:05:48 +05:30
pandeymangg
2e17e70c00 fix: isDirty changes with react hook form 2024-05-24 13:49:00 +05:30
pandeymangg
641b597ddc isDirty 2024-05-24 12:11:30 +05:30
pandeymangg
b6986e5470 Merge branch 'main' into fix/product-settings-form 2024-05-23 11:30:45 +05:30
pandeymangg
82a5eed6e5 fix: Form element 2024-05-23 11:28:16 +05:30
pandeymangg
0f20a4460f fix: placement form 2024-05-23 10:41:33 +05:30
pandeymangg
95ae35a3b5 fix: adds form component and refactors the current approach 2024-05-23 10:19:05 +05:30
pandeymangg
ddd91607b1 Merge branch 'main' into fix/product-settings-form 2024-05-22 18:46:32 +05:30
pandeymangg
325eeb10ef fix: look and feel settings react hook form 2024-05-22 18:34:44 +05:30
15 changed files with 614 additions and 310 deletions

View File

@@ -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,

View File

@@ -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&apos;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>
);
};

View File

@@ -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&apos;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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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"

View File

@@ -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&apos;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>
);
};

View File

@@ -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&apos;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>
);
};

View File

@@ -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"

View File

@@ -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",

View File

@@ -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
View 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,
};

View File

@@ -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
View File

@@ -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: