mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-06 05:40:02 -06:00
fix: adds form component and refactors the current approach
This commit is contained in:
@@ -6,10 +6,10 @@ import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TProduct, ZProduct } from "@formbricks/types/product";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@formbricks/ui/Form";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
|
||||
import { updateProductAction } from "../actions";
|
||||
|
||||
@@ -19,9 +19,7 @@ type EditProductNameProps = {
|
||||
isProductNameEditDisabled: boolean;
|
||||
};
|
||||
|
||||
const editProductNameSchema = z.object({
|
||||
name: z.string().trim().min(1, { message: "Product name cannot be empty" }),
|
||||
});
|
||||
const editProductNameSchema = ZProduct.pick({ name: true });
|
||||
|
||||
type TEditProductName = z.infer<typeof editProductNameSchema>;
|
||||
|
||||
@@ -31,11 +29,7 @@ export const EditProductNameForm: React.FC<EditProductNameProps> = ({
|
||||
isProductNameEditDisabled,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting, errors },
|
||||
} = useForm<TEditProductName>({
|
||||
const form = useForm<TEditProductName>({
|
||||
defaultValues: {
|
||||
name: product.name,
|
||||
},
|
||||
@@ -43,7 +37,8 @@ export const EditProductNameForm: React.FC<EditProductNameProps> = ({
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const nameError = errors.name?.message;
|
||||
const nameError = form.formState.errors.name?.message;
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
const updateProduct: SubmitHandler<TEditProductName> = async (data) => {
|
||||
const name = data.name.trim();
|
||||
@@ -54,7 +49,7 @@ export const EditProductNameForm: React.FC<EditProductNameProps> = ({
|
||||
}
|
||||
|
||||
if (name === product.name) {
|
||||
toast.success("This is already your product name");
|
||||
form.setError("name", { type: "manual", message: "Product name is the same" }, { shouldFocus: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -62,7 +57,7 @@ export const EditProductNameForm: React.FC<EditProductNameProps> = ({
|
||||
|
||||
if (isProductNameEditDisabled) {
|
||||
toast.error("Only Owners, Admins and Editors can perform this action.");
|
||||
throw new Error();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!!updatedProduct?.id) {
|
||||
@@ -76,18 +71,35 @@ export const EditProductNameForm: React.FC<EditProductNameProps> = ({
|
||||
};
|
||||
|
||||
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")} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="darkCTA"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
disabled={!!nameError || isSubmitting}>
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
<Form {...form}>
|
||||
<form className="w-full max-w-sm items-center space-y-2" onSubmit={form.handleSubmit(updateProduct)}>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" variant="darkCTA" size="sm" loading={isSubmitting} disabled={isSubmitting}>
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
) : (
|
||||
<p className="text-sm text-red-700">Only Owners, Admins and Editors can perform this action.</p>
|
||||
);
|
||||
|
||||
@@ -6,10 +6,10 @@ import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TProduct, ZProduct } from "@formbricks/types/product";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@formbricks/ui/Form";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
|
||||
import { updateProductAction } from "../actions";
|
||||
|
||||
@@ -18,22 +18,14 @@ type EditWaitingTimeProps = {
|
||||
product: TProduct;
|
||||
};
|
||||
|
||||
const editWaitingTimeSchema = z.object({
|
||||
recontactDays: z
|
||||
.number({ message: "Recontact days is required" })
|
||||
.min(0, { message: "Must be a positive number" })
|
||||
.max(365, { message: "Must be less than 365" }),
|
||||
});
|
||||
const editWaitingTimeSchema = ZProduct.pick({ recontactDays: true });
|
||||
|
||||
type EditWaitingTimeFormValues = z.infer<typeof editWaitingTimeSchema>;
|
||||
|
||||
export const EditWaitingTimeForm: React.FC<EditWaitingTimeProps> = ({ product, environmentId }) => {
|
||||
const router = useRouter();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<EditWaitingTimeFormValues>({
|
||||
|
||||
const form = useForm<EditWaitingTimeFormValues>({
|
||||
defaultValues: {
|
||||
recontactDays: product.recontactDays,
|
||||
},
|
||||
@@ -54,26 +46,40 @@ export const EditWaitingTimeForm: React.FC<EditWaitingTimeProps> = ({ product, e
|
||||
};
|
||||
|
||||
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", {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="w-full max-w-sm items-center space-y-2"
|
||||
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("");
|
||||
}
|
||||
|
||||
{errors?.recontactDays ? (
|
||||
<div className="my-2">
|
||||
<p className="text-xs text-red-500">{errors?.recontactDays?.message}</p>
|
||||
</div>
|
||||
) : null}
|
||||
field.onChange(parseInt(value, 10));
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" variant="darkCTA" size="sm" disabled={Object.keys(errors).length > 0}>
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
<Button type="submit" variant="darkCTA" size="sm">
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
147
packages/ui/Form/index.tsx
Normal file
147
packages/ui/Form/index.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"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";
|
||||
|
||||
const Form = FormProvider;
|
||||
|
||||
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, Form, 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}
|
||||
|
||||
Reference in New Issue
Block a user