feat: expose form validation methods from EventTypePlatformWrapper (#22655)

* feat: expose form validation methods from EventTypePlatformWrapper

- Add validateForm and handleFormSubmit methods to onFormStateChange prop
- Update useEventTypeForm hook to return validateForm method
- Add proper TypeScript types for form validation result
- Enable external form validation and submission capabilities

Co-Authored-By: somay@cal.com <somay@cal.com>

* fix: correct forwardRef syntax to resolve parsing errors

- Fix forwardRef parameter destructuring syntax
- Use proper props parameter pattern for forwardRef components
- Ensure prettier can parse the file correctly

Co-Authored-By: somay@cal.com <somay@cal.com>

* feat: add form validation and submission handlers to event types platform wrapper

* refactor: move form validation and submit handlers from hook to wrapper component

* fix: add ref to the save button for proper form validation

* feat: extend ref-based validation API to AvailabilitySettingsPlatformWrapper

- Add AvailabilityFormValidationResult and AvailabilitySettingsPlatformWrapperRef types
- Implement forwardRef pattern in AvailabilitySettings component with save button ref
- Add validateForm and handleFormSubmit methods using useImperativeHandle
- Update AvailabilitySettingsPlatformWrapper to support ref forwarding
- Add comprehensive documentation for ref-based validation API in both event-type.mdx and availability-settings.mdx
- Follow same patterns established in EventTypePlatformWrapper for consistency

Co-Authored-By: somay@cal.com <somay@cal.com>

* refactor: rename AvailabilitySettingsPlatformWrapperRef to AvailabilitySettingsFormRef

* feat: add ref-based validation pattern to availability example page

- Mirror the ref-based validation implementation from event-types.tsx
- Add useRef, handleValidate, and handleSubmit functions
- Demonstrate validateForm() and handleFormSubmit() methods
- Add validation and submit buttons to showcase the new API
- Use AvailabilitySettingsFormRef type for proper typing

Co-Authored-By: somay@cal.com <somay@cal.com>

* refactor: remove unused state variables

* docs: clarify form validation behavior for availability and event type atoms

* chore: remove console.logs

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: somay@cal.com <somay@cal.com>
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
This commit is contained in:
devin-ai-integration[bot]
2025-07-23 15:28:09 +05:30
committed by GitHub
co-authored by somay@cal.com Somay Chauhan
parent 16a7bb7bc5
commit eb4eebe6f9
15 changed files with 755 additions and 455 deletions
@@ -133,3 +133,59 @@ Along with the props, Availability settings atom accepts custom styles via the *
| overridesModalClassNames | Adds styling to the date overrides modal |
<Info>Please ensure all custom classnames are valid [Tailwind CSS](https://tailwindcss.com/) classnames.</Info>
## Programmatic Form Validation and Submission
The AvailabilitySettings component supports programmatic form validation and submission through a ref-based API. This allows you to validate availability data and submit forms programmatically without user interaction.
```js
import { useRef } from 'react';
import { AvailabilitySettings } from "@calcom/atoms";
import type { AvailabilitySettingsFormRef } from "@calcom/atoms";
export function AvailabilityWithValidation() {
const availabilityRef = useRef<AvailabilitySettingsFormRef>(null);
const handleValidate = async () => {
const result = await availabilityRef.current?.validateForm();
if (result?.isValid) {
console.log("Availability form is valid");
} else {
console.log("Validation errors:", result?.errors);
}
};
const handleSubmit = () => {
availabilityRef.current?.handleFormSubmit();
};
return (
<>
<AvailabilitySettings
ref={availabilityRef}
onUpdateSuccess={() => {
console.log("Updated schedule successfully");
}}
onFormStateChange={(formState) => {
console.log("Form state changed:", formState);
}}
/>
<button onClick={handleValidate}>Validate Form</button>
<button onClick={handleSubmit}>Submit Form</button>
</>
);
}
```
### Ref Methods
| Method | Description |
|:-------|:------------|
| validateForm | Validates the current availability form state and returns a promise with validation results. |
| handleFormSubmit | Programmatically submits the availability form, triggering the same validation and submission flow as clicking the save button. Unlike `validateForm`, this method will check required fields and prevent submission unless all required fields are set |
The `validateForm` method returns an `AvailabilityFormValidationResult` object with:
- `isValid`: Boolean indicating if the form passed validation
- `errors`: Object containing any validation errors found
**Note:** If a required field is not filled in, the `validateForm` method will not return any error. The validation focuses on the format and consistency of provided data rather than enforcing required field completion.
+54
View File
@@ -196,6 +196,60 @@ Along with the props, event type settings atom accepts custom styles via the **c
Please ensure all custom classnames are valid [Tailwind CSS](https://tailwindcss.com/) classnames.
</Info>
## Programmatic Form Validation and Submission
The EventTypeSettings component supports programmatic form validation and submission through a ref-based API. This allows you to validate form data and submit forms programmatically without user interaction.
```js
import { useRef } from 'react';
import { EventTypeSettings } from "@calcom/atoms";
import type { EventTypePlatformWrapperRef } from "@calcom/atoms";
export function EventTypeWithValidation(eventTypeId: number) {
const eventTypeRef = useRef<EventTypePlatformWrapperRef>(null);
const handleValidate = async () => {
const result = await eventTypeRef.current?.validateForm();
if (result?.isValid) {
console.log("Form is valid");
} else {
console.log("Validation errors:", result?.errors);
}
};
const handleSubmit = () => {
eventTypeRef.current?.handleFormSubmit();
};
return (
<>
<EventTypeSettings
ref={eventTypeRef}
id={eventTypeId}
onSuccess={(eventType) => {
console.log("EventType updated successfully", eventType);
}}
/>
<button onClick={handleValidate}>Validate Form</button>
<button onClick={handleSubmit}>Submit Form</button>
</>
);
}
```
### Ref Methods
| Method | Description |
|:-------|:------------|
| validateForm | Validates the current event type form state and returns a promise with validation results. |
| handleFormSubmit | Programmatically submits the event type form, triggering the same validation and submission flow as clicking the save button. Unlike `validateForm`, this method will check required fields and prevent submission unless all required fields are set |
The `validateForm` method returns an `EventTypeFormValidationResult` object with:
- `isValid`: Boolean indicating if the form passed validation
- `errors`: Object containing any validation errors found
**Note:** If a required field is not filled in, the `validateForm` method will not return any error. The validation focuses on the format and consistency of provided data rather than enforcing required field completion.
Following are the tabs that are available in the event type settings atom:
<p></p>
@@ -64,6 +64,7 @@ export type EventTypeComponentProps = EventTypeSetupProps & {
tabName: (typeof tabs)[number];
tabsNavigation: VerticalTabItemProps[];
allowDelete?: boolean;
saveButtonRef?: React.RefObject<HTMLButtonElement>;
};
export const EventType = ({
@@ -82,6 +83,7 @@ export const EventType = ({
handleSubmit,
children,
allowDelete = true,
saveButtonRef,
}: EventTypeComponentProps) => {
const [animationParentRef] = useAutoAnimate<HTMLDivElement>();
@@ -101,7 +103,8 @@ export const EventType = ({
isDeleting={isDeleting}
isPlatform={isPlatform}
allowDelete={allowDelete}
tabsNavigation={tabsNavigation}>
tabsNavigation={tabsNavigation}
saveButtonRef={saveButtonRef}>
<Form form={formMethods} id="event-type-form" handleSubmit={handleSubmit}>
<div ref={animationParentRef}>{tabMap[tabName]}</div>
</Form>
@@ -47,6 +47,7 @@ type Props = {
isPlatform?: boolean;
tabsNavigation: VerticalTabItemProps[];
allowDelete?: boolean;
saveButtonRef?: React.RefObject<HTMLButtonElement>;
};
function EventTypeSingleLayout({
@@ -64,6 +65,7 @@ function EventTypeSingleLayout({
isPlatform,
tabsNavigation,
allowDelete = true,
saveButtonRef,
}: Props) {
const { t } = useLocale();
const eventTypesLockedByOrg = eventType.team?.parent?.organizationSettings?.lockEventTypeCreationForUsers;
@@ -262,6 +264,7 @@ function EventTypeSingleLayout({
</Dropdown>
<div className="border-default border-l-2" />
<Button
ref={saveButtonRef}
className="ml-4 lg:ml-0"
type="submit"
loading={isUpdateMutationLoading}
+10
View File
@@ -220,3 +220,13 @@ export type SelectClassNames = {
label?: string;
container?: string;
};
export type FormValidationResult = {
isValid: boolean;
errors: Record<string, unknown>;
};
export interface EventTypePlatformWrapperRef {
validateForm: () => Promise<FormValidationResult>;
handleFormSubmit: () => void;
}
@@ -1,7 +1,15 @@
"use client";
import type { SetStateAction, Dispatch } from "react";
import React, { useMemo, useState, useEffect } from "react";
import React, {
useMemo,
useState,
useEffect,
forwardRef,
useImperativeHandle,
useRef,
useCallback,
} from "react";
import { Controller, useFieldArray, useForm, useFormContext, useWatch } from "react-hook-form";
import dayjs from "@calcom/dayjs";
@@ -38,7 +46,7 @@ import { Tooltip } from "@calcom/ui/components/tooltip";
import { Shell as PlatformShell } from "../src/components/ui/shell";
import { cn } from "../src/lib/utils";
import { Timezone as PlatformTimzoneSelect } from "../timezone/index";
import type { AvailabilityFormValues, scheduleClassNames } from "./types";
import type { AvailabilityFormValues, scheduleClassNames, AvailabilitySettingsFormRef } from "./types";
export type Schedule = {
id: number;
@@ -257,406 +265,447 @@ const SmallScreenSideBar = ({ open, children }: { open: boolean; children: JSX.E
);
};
export function AvailabilitySettings({
schedule,
travelSchedules,
handleDelete,
isDeleting,
isLoading,
isSaving,
timeFormat,
weekStart,
backPath,
handleSubmit,
isPlatform = false,
customClassNames,
disableEditableHeading = false,
enableOverrides = false,
onFormStateChange,
bulkUpdateModalProps,
allowSetToDefault = true,
allowDelete = true,
}: AvailabilitySettingsProps) {
const [openSidebar, setOpenSidebar] = useState(false);
const { t, i18n } = useLocale();
export const AvailabilitySettings = forwardRef<AvailabilitySettingsFormRef, AvailabilitySettingsProps>(
function AvailabilitySettings(props, ref) {
const {
schedule,
travelSchedules,
handleDelete,
isDeleting,
isLoading,
isSaving,
timeFormat,
weekStart,
backPath,
handleSubmit,
isPlatform = false,
customClassNames,
disableEditableHeading = false,
enableOverrides = false,
onFormStateChange,
bulkUpdateModalProps,
allowSetToDefault = true,
allowDelete = true,
} = props;
const [openSidebar, setOpenSidebar] = useState(false);
const { t, i18n } = useLocale();
const form = useForm<AvailabilityFormValues>({
defaultValues: {
...schedule,
schedule: schedule.availability || [],
},
});
const form = useForm<AvailabilityFormValues>({
defaultValues: {
...schedule,
schedule: schedule.availability || [],
},
});
const watchedValues = useWatch({
control: form.control,
});
const watchedValues = useWatch({
control: form.control,
});
// Trigger callback whenever the form state changes
useEffect(() => {
if (onFormStateChange && watchedValues) {
onFormStateChange(watchedValues as AvailabilityFormValues);
}
}, [watchedValues, onFormStateChange]);
const [Shell, Schedule, TimezoneSelect] = useMemo(() => {
return isPlatform
? [PlatformShell, PlatformSchedule, PlatformTimzoneSelect]
: [WebShell, WebSchedule, WebTimezoneSelect];
}, [isPlatform]);
return (
<Shell
headerClassName={cn(customClassNames?.containerClassName)}
backPath={backPath}
title={schedule.name ? `${schedule.name} | ${t("availability")}` : t("availability")}
heading={
<Controller
control={form.control}
name="name"
render={({ field }) => (
<EditableHeading
className={cn(customClassNames?.editableHeadingClassName)}
isReady={!isLoading}
disabled={disableEditableHeading}
{...field}
data-testid="availablity-title"
/>
)}
/>
// Trigger callback whenever the form state changes
useEffect(() => {
if (onFormStateChange && watchedValues) {
onFormStateChange(watchedValues as AvailabilityFormValues);
}
subtitle={
schedule ? (
schedule.schedule
.filter((availability) => !!availability.days.length)
.map((availability) =>
availabilityAsString(availability, {
locale: i18n.language,
hour12: timeFormat === 12,
})
)
// sort the availability strings as per user's weekstart (settings)
.sort(sortAvailabilityStrings(i18n.language, weekStart))
.map((availabilityString, index) => (
<span key={index} className={cn(customClassNames?.subtitlesClassName)}>
{availabilityString}
<br />
</span>
))
) : (
<SkeletonText className="h-4 w-48" />
)
}, [watchedValues, onFormStateChange]);
const [Shell, Schedule, TimezoneSelect] = useMemo(() => {
return isPlatform
? [PlatformShell, PlatformSchedule, PlatformTimzoneSelect]
: [WebShell, WebSchedule, WebTimezoneSelect];
}, [isPlatform]);
const saveButtonRef = useRef<HTMLButtonElement>(null);
const handleFormSubmit = useCallback(() => {
if (saveButtonRef.current) {
saveButtonRef.current.click();
} else {
form.handleSubmit(handleSubmit)();
}
CTA={
<div className={cn(customClassNames?.ctaClassName, "flex items-center justify-end")}>
<div className="sm:hover:bg-muted hidden items-center rounded-md px-2 transition sm:flex">
{!openSidebar && allowSetToDefault ? (
<>
<Skeleton
as={Label}
htmlFor="hiddenSwitch"
className="mt-2 cursor-pointer self-center pe-2"
loadingClassName="me-4"
waitForTranslation={!isPlatform}>
{t("set_to_default")}
</Skeleton>
<Controller
control={form.control}
name="isDefault"
render={({ field: { value, onChange } }) => (
<Switch
id="hiddenSwitch"
classNames={{
container: cn(customClassNames?.hiddenSwitchClassname?.container),
thumb: cn(customClassNames?.hiddenSwitchClassname?.thumb),
}}
disabled={isSaving || schedule.isDefault}
checked={value}
onCheckedChange={(checked) => {
onChange(checked);
bulkUpdateModalProps?.setIsOpen(checked);
}}
/>
)}
/>
<VerticalDivider className="hidden sm:inline" />
</>
) : null}
</div>
}, [form, handleSubmit]);
{bulkUpdateModalProps && bulkUpdateModalProps?.isOpen && (
<BulkEditDefaultForEventsModal
isPending={bulkUpdateModalProps?.isSaving}
open={bulkUpdateModalProps?.isOpen}
setOpen={bulkUpdateModalProps.setIsOpen}
bulkUpdateFunction={bulkUpdateModalProps?.save}
description={t("default_schedules_bulk_description")}
eventTypes={bulkUpdateModalProps?.eventTypes}
isEventTypesFetching={bulkUpdateModalProps?.isEventTypesFetching}
handleBulkEditDialogToggle={bulkUpdateModalProps.handleBulkEditDialogToggle}
/>
)}
const validateForm = useCallback(async () => {
const isValid = await form.trigger();
return {
isValid,
errors: form.formState.errors,
};
}, [form]);
{allowDelete && (
<>
<DeleteDialogButton
buttonClassName={cn("hidden me-2 sm:inline", customClassNames?.deleteButtonClassname)}
disabled={schedule.isLastSchedule}
isPending={isDeleting}
handleDelete={handleDelete}
useImperativeHandle(
ref,
() => ({
validateForm,
handleFormSubmit,
}),
[validateForm, handleFormSubmit]
);
return (
<Shell
headerClassName={cn(customClassNames?.containerClassName)}
backPath={backPath}
title={schedule.name ? `${schedule.name} | ${t("availability")}` : t("availability")}
heading={
<Controller
control={form.control}
name="name"
render={({ field }) => (
<EditableHeading
className={cn(customClassNames?.editableHeadingClassName)}
isReady={!isLoading}
disabled={disableEditableHeading}
{...field}
data-testid="availablity-title"
/>
<VerticalDivider className="hidden sm:inline" />
</>
)}
<SmallScreenSideBar open={openSidebar}>
<>
<div
className={classNames(
openSidebar
? "fadeIn fixed inset-0 z-50 bg-neutral-800 bg-opacity-70 transition-opacity dark:bg-opacity-70 sm:hidden"
: ""
)}>
<div
className={classNames(
"bg-default fixed right-0 z-20 flex h-screen w-80 flex-col space-y-2 overflow-x-hidden rounded-md px-2 pb-3 transition-transform",
openSidebar ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"
)}>
<div className="flex flex-row items-center pt-16">
<Button StartIcon="arrow-left" color="minimal" onClick={() => setOpenSidebar(false)} />
<p className="-ml-2">{t("availability_settings")}</p>
{allowDelete && (
<DeleteDialogButton
buttonClassName={cn("ml-16 inline", customClassNames?.deleteButtonClassname)}
disabled={schedule.isLastSchedule}
isPending={isDeleting}
handleDelete={handleDelete}
onDeleteConfirmed={() => {
setOpenSidebar(false);
)}
/>
}
subtitle={
schedule ? (
schedule.schedule
.filter((availability) => !!availability.days.length)
.map((availability) =>
availabilityAsString(availability, {
locale: i18n.language,
hour12: timeFormat === 12,
})
)
// sort the availability strings as per user's weekstart (settings)
.sort(sortAvailabilityStrings(i18n.language, weekStart))
.map((availabilityString, index) => (
<span key={index} className={cn(customClassNames?.subtitlesClassName)}>
{availabilityString}
<br />
</span>
))
) : (
<SkeletonText className="h-4 w-48" />
)
}
CTA={
<div className={cn(customClassNames?.ctaClassName, "flex items-center justify-end")}>
<div className="sm:hover:bg-muted hidden items-center rounded-md px-2 transition sm:flex">
{!openSidebar && allowSetToDefault ? (
<>
<Skeleton
as={Label}
htmlFor="hiddenSwitch"
className="mt-2 cursor-pointer self-center pe-2"
loadingClassName="me-4"
waitForTranslation={!isPlatform}>
{t("set_to_default")}
</Skeleton>
<Controller
control={form.control}
name="isDefault"
render={({ field: { value, onChange } }) => (
<Switch
id="hiddenSwitch"
classNames={{
container: cn(customClassNames?.hiddenSwitchClassname?.container),
thumb: cn(customClassNames?.hiddenSwitchClassname?.thumb),
}}
disabled={isSaving || schedule.isDefault}
checked={value}
onCheckedChange={(checked) => {
onChange(checked);
bulkUpdateModalProps?.setIsOpen(checked);
}}
/>
)}
</div>
<div className="flex flex-col px-2 py-2">
<Skeleton as={Label} waitForTranslation={!isPlatform}>
{t("name")}
</Skeleton>
<Controller
control={form.control}
name="name"
render={({ field }) => (
<input
className="hover:border-emphasis dark:focus:border-emphasis border-default bg-default placeholder:text-muted text-emphasis focus:ring-brand-default disabled:bg-subtle disabled:hover:border-subtle focus:border-subtle mb-2 block h-9 w-full rounded-md border px-3 py-2 text-sm leading-4 focus:outline-none focus:ring-2 disabled:cursor-not-allowed"
{...field}
/>
<VerticalDivider className="hidden sm:inline" />
</>
) : null}
</div>
{bulkUpdateModalProps && bulkUpdateModalProps?.isOpen && (
<BulkEditDefaultForEventsModal
isPending={bulkUpdateModalProps?.isSaving}
open={bulkUpdateModalProps?.isOpen}
setOpen={bulkUpdateModalProps.setIsOpen}
bulkUpdateFunction={bulkUpdateModalProps?.save}
description={t("default_schedules_bulk_description")}
eventTypes={bulkUpdateModalProps?.eventTypes}
isEventTypesFetching={bulkUpdateModalProps?.isEventTypesFetching}
handleBulkEditDialogToggle={bulkUpdateModalProps.handleBulkEditDialogToggle}
/>
)}
{allowDelete && (
<>
<DeleteDialogButton
buttonClassName={cn("hidden me-2 sm:inline", customClassNames?.deleteButtonClassname)}
disabled={schedule.isLastSchedule}
isPending={isDeleting}
handleDelete={handleDelete}
/>
<VerticalDivider className="hidden sm:inline" />
</>
)}
<SmallScreenSideBar open={openSidebar}>
<>
<div
className={classNames(
openSidebar
? "fadeIn fixed inset-0 z-50 bg-neutral-800 bg-opacity-70 transition-opacity dark:bg-opacity-70 sm:hidden"
: ""
)}>
<div
className={classNames(
"bg-default fixed right-0 z-20 flex h-screen w-80 flex-col space-y-2 overflow-x-hidden rounded-md px-2 pb-3 transition-transform",
openSidebar ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"
)}>
<div className="flex flex-row items-center pt-16">
<Button StartIcon="arrow-left" color="minimal" onClick={() => setOpenSidebar(false)} />
<p className="-ml-2">{t("availability_settings")}</p>
{allowDelete && (
<DeleteDialogButton
buttonClassName={cn("ml-16 inline", customClassNames?.deleteButtonClassname)}
disabled={schedule.isLastSchedule}
isPending={isDeleting}
handleDelete={handleDelete}
onDeleteConfirmed={() => {
setOpenSidebar(false);
}}
/>
)}
/>
</div>
<div className="flex h-9 flex-row-reverse items-center justify-end gap-3 px-2">
{allowSetToDefault && (
<>
<Skeleton
as={Label}
htmlFor="hiddenSwitch"
className="mt-2 cursor-pointer self-center pr-2 sm:inline"
waitForTranslation={!isPlatform}>
{t("set_to_default")}
</Skeleton>
<Controller
control={form.control}
name="isDefault"
render={({ field: { value, onChange } }) => (
<Switch
classNames={{
container: cn(customClassNames?.hiddenSwitchClassname?.container),
thumb: cn(customClassNames?.hiddenSwitchClassname?.thumb),
}}
id="hiddenSwitch"
disabled={isSaving || value}
checked={value}
onCheckedChange={onChange}
/>
)}
/>
</>
)}
</div>
<div className="min-w-40 col-span-3 space-y-2 px-2 py-4 lg:col-span-1">
<div className="xl:max-w-80 w-full pr-4 sm:ml-0 sm:mr-36 sm:p-0">
<div>
<Skeleton
as={Label}
htmlFor="timeZone-sm-viewport"
className="mb-0 inline-block leading-none"
waitForTranslation={!isPlatform}>
{t("timezone")}
</Skeleton>
<Controller
control={form.control}
name="timeZone"
render={({ field: { onChange, value } }) =>
value ? (
<TimezoneSelect
inputId="timeZone-sm-viewport"
value={value}
className={cn(
"focus:border-brand-default border-default mt-1 block w-72 rounded-md text-sm",
customClassNames?.timezoneSelectClassName
)}
onChange={(timezone) => onChange(timezone.value)}
/>
) : (
<SelectSkeletonLoader className="mt-1 w-72" />
)
}
/>
</div>
{!isPlatform && (
</div>
<div className="flex flex-col px-2 py-2">
<Skeleton as={Label} waitForTranslation={!isPlatform}>
{t("name")}
</Skeleton>
<Controller
control={form.control}
name="name"
render={({ field }) => (
<input
className="hover:border-emphasis dark:focus:border-emphasis border-default bg-default placeholder:text-muted text-emphasis focus:ring-brand-default disabled:bg-subtle disabled:hover:border-subtle focus:border-subtle mb-2 block h-9 w-full rounded-md border px-3 py-2 text-sm leading-4 focus:outline-none focus:ring-2 disabled:cursor-not-allowed"
{...field}
/>
)}
/>
</div>
<div className="flex h-9 flex-row-reverse items-center justify-end gap-3 px-2">
{allowSetToDefault && (
<>
<hr className="border-subtle my-7" />
<div className="rounded-md md:block">
<Skeleton
as="h3"
className="mb-0 inline-block text-sm font-medium"
waitForTranslation={!isPlatform}>
{t("something_doesnt_look_right")}
</Skeleton>
<div className="mt-3 flex">
<Skeleton
as={Button}
href="/availability/troubleshoot"
color="secondary"
waitForTranslation={!isPlatform}>
{t("launch_troubleshooter")}
</Skeleton>
</div>
</div>
<Skeleton
as={Label}
htmlFor="hiddenSwitch"
className="mt-2 cursor-pointer self-center pr-2 sm:inline"
waitForTranslation={!isPlatform}>
{t("set_to_default")}
</Skeleton>
<Controller
control={form.control}
name="isDefault"
render={({ field: { value, onChange } }) => (
<Switch
classNames={{
container: cn(customClassNames?.hiddenSwitchClassname?.container),
thumb: cn(customClassNames?.hiddenSwitchClassname?.thumb),
}}
id="hiddenSwitch"
disabled={isSaving || value}
checked={value}
onCheckedChange={onChange}
/>
)}
/>
</>
)}
</div>
<div className="min-w-40 col-span-3 space-y-2 px-2 py-4 lg:col-span-1">
<div className="xl:max-w-80 w-full pr-4 sm:ml-0 sm:mr-36 sm:p-0">
<div>
<Skeleton
as={Label}
htmlFor="timeZone-sm-viewport"
className="mb-0 inline-block leading-none"
waitForTranslation={!isPlatform}>
{t("timezone")}
</Skeleton>
<Controller
control={form.control}
name="timeZone"
render={({ field: { onChange, value } }) =>
value ? (
<TimezoneSelect
inputId="timeZone-sm-viewport"
value={value}
className={cn(
"focus:border-brand-default border-default mt-1 block w-72 rounded-md text-sm",
customClassNames?.timezoneSelectClassName
)}
onChange={(timezone) => onChange(timezone.value)}
/>
) : (
<SelectSkeletonLoader className="mt-1 w-72" />
)
}
/>
</div>
{!isPlatform && (
<>
<hr className="border-subtle my-7" />
<div className="rounded-md md:block">
<Skeleton
as="h3"
className="mb-0 inline-block text-sm font-medium"
waitForTranslation={!isPlatform}>
{t("something_doesnt_look_right")}
</Skeleton>
<div className="mt-3 flex">
<Skeleton
as={Button}
href="/availability/troubleshoot"
color="secondary"
waitForTranslation={!isPlatform}>
{t("launch_troubleshooter")}
</Skeleton>
</div>
</div>
</>
)}
</div>
</div>
</div>
</div>
</>
</SmallScreenSideBar>
<div className="border-default border-l-2" />
<Button
ref={saveButtonRef}
className="ml-4 lg:ml-0"
type="submit"
form="availability-form"
loading={isSaving}>
{t("save")}
</Button>
<Button
className="ml-3 sm:hidden"
StartIcon="ellipsis-vertical"
variant="icon"
color="secondary"
onClick={() => setOpenSidebar(true)}
/>
</div>
}>
<div className="mt-4 w-full md:mt-0">
<Form
form={form}
id="availability-form"
handleSubmit={async (props) => {
handleSubmit(props);
}}
className={cn(customClassNames?.formClassName, "flex flex-col sm:mx-0 xl:flex-row xl:space-x-6")}>
<div className="flex-1 flex-row xl:mr-0">
<div
className={cn(
"border-subtle mb-6 rounded-md border",
customClassNames?.scheduleClassNames?.scheduleContainer
)}>
<div>
{typeof weekStart === "string" && (
<Schedule
labels={{
addTime: t("add_time_availability"),
copyTime: t("copy_times_to"),
deleteTime: t("delete"),
}}
classNames={
customClassNames?.scheduleClassNames ? { ...customClassNames.scheduleClassNames } : {}
}
control={form.control}
name="schedule"
userTimeFormat={timeFormat}
weekStart={
[
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
].indexOf(weekStart) as 0 | 1 | 2 | 3 | 4 | 5 | 6
}
/>
)}
</div>
</div>
</>
</SmallScreenSideBar>
<div className="border-default border-l-2" />
<Button className="ml-4 lg:ml-0" type="submit" form="availability-form" loading={isSaving}>
{t("save")}
</Button>
<Button
className="ml-3 sm:hidden"
StartIcon="ellipsis-vertical"
variant="icon"
color="secondary"
onClick={() => setOpenSidebar(true)}
/>
</div>
}>
<div className="mt-4 w-full md:mt-0">
<Form
form={form}
id="availability-form"
handleSubmit={async (props) => {
handleSubmit(props);
}}
className={cn(customClassNames?.formClassName, "flex flex-col sm:mx-0 xl:flex-row xl:space-x-6")}>
<div className="flex-1 flex-row xl:mr-0">
<div
className={cn(
"border-subtle mb-6 rounded-md border",
customClassNames?.scheduleClassNames?.scheduleContainer
)}>
<div>
{typeof weekStart === "string" && (
<Schedule
labels={{
addTime: t("add_time_availability"),
copyTime: t("copy_times_to"),
deleteTime: t("delete"),
}}
classNames={
customClassNames?.scheduleClassNames ? { ...customClassNames.scheduleClassNames } : {}
}
control={form.control}
name="schedule"
userTimeFormat={timeFormat}
weekStart={
["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"].indexOf(
weekStart
) as 0 | 1 | 2 | 3 | 4 | 5 | 6
{enableOverrides && (
<DateOverride
workingHours={schedule.workingHours}
userTimeFormat={timeFormat}
handleSubmit={handleSubmit}
travelSchedules={travelSchedules}
weekStart={
["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"].indexOf(
weekStart
) as 0 | 1 | 2 | 3 | 4 | 5 | 6
}
overridesModalClassNames={customClassNames?.overridesModalClassNames}
/>
)}
</div>
<div className="min-w-40 col-span-3 hidden space-y-2 md:block lg:col-span-1">
<div className="xl:max-w-80 w-full pr-4 sm:ml-0 sm:mr-36 sm:p-0">
<div>
<Skeleton
as={Label}
htmlFor="timeZone-lg-viewport"
className="mb-0 inline-block leading-none"
waitForTranslation={!isPlatform}>
{t("timezone")}
</Skeleton>
<Controller
name="timeZone"
render={({ field: { onChange, value } }) =>
value ? (
<TimezoneSelect
inputId="timeZone-lg-viewport"
value={value}
className="focus:border-brand-default border-default mt-1 block w-72 rounded-md text-sm"
onChange={(timezone) => onChange(timezone.value)}
/>
) : (
<SelectSkeletonLoader className="mt-1 w-72" />
)
}
/>
</div>
{isPlatform ? (
<></>
) : (
<>
<hr className="border-subtle my-6 mr-8" />
<div className="rounded-md">
<Skeleton
as="h3"
className="mb-0 inline-block text-sm font-medium"
waitForTranslation={!isPlatform}>
{t("something_doesnt_look_right")}
</Skeleton>
<div className="mt-3 flex">
<Skeleton
as={Button}
href="/availability/troubleshoot"
color="secondary"
waitForTranslation={!isPlatform}>
{t("launch_troubleshooter")}
</Skeleton>
</div>
</div>
</>
)}
</div>
</div>
{enableOverrides && (
<DateOverride
workingHours={schedule.workingHours}
userTimeFormat={timeFormat}
handleSubmit={handleSubmit}
travelSchedules={travelSchedules}
weekStart={
["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"].indexOf(
weekStart
) as 0 | 1 | 2 | 3 | 4 | 5 | 6
}
overridesModalClassNames={customClassNames?.overridesModalClassNames}
/>
)}
</div>
<div className="min-w-40 col-span-3 hidden space-y-2 md:block lg:col-span-1">
<div className="xl:max-w-80 w-full pr-4 sm:ml-0 sm:mr-36 sm:p-0">
<div>
<Skeleton
as={Label}
htmlFor="timeZone-lg-viewport"
className="mb-0 inline-block leading-none"
waitForTranslation={!isPlatform}>
{t("timezone")}
</Skeleton>
<Controller
name="timeZone"
render={({ field: { onChange, value } }) =>
value ? (
<TimezoneSelect
inputId="timeZone-lg-viewport"
value={value}
className="focus:border-brand-default border-default mt-1 block w-72 rounded-md text-sm"
onChange={(timezone) => onChange(timezone.value)}
/>
) : (
<SelectSkeletonLoader className="mt-1 w-72" />
)
}
/>
</div>
{isPlatform ? (
<></>
) : (
<>
<hr className="border-subtle my-6 mr-8" />
<div className="rounded-md">
<Skeleton
as="h3"
className="mb-0 inline-block text-sm font-medium"
waitForTranslation={!isPlatform}>
{t("something_doesnt_look_right")}
</Skeleton>
<div className="mt-3 flex">
<Skeleton
as={Button}
href="/availability/troubleshoot"
color="secondary"
waitForTranslation={!isPlatform}>
{t("launch_troubleshooter")}
</Skeleton>
</div>
</div>
</>
)}
</div>
</div>
</Form>
</div>
</Shell>
);
}
</Form>
</div>
</Shell>
);
}
);
@@ -29,3 +29,13 @@ export type scheduleClassNames = {
labelAndSwitchContainer?: string;
scheduleContainer?: string;
};
export type AvailabilityFormValidationResult = {
isValid: boolean;
errors: Record<string, unknown>;
};
export interface AvailabilitySettingsFormRef {
validateForm: () => Promise<AvailabilityFormValidationResult>;
handleFormSubmit: () => void;
}
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import { forwardRef } from "react";
import type { ScheduleLabelsType } from "@calcom/features/schedules/components/Schedule";
import type { UpdateScheduleResponse } from "@calcom/lib/schedules/updateSchedule";
@@ -11,7 +12,7 @@ import { useMe } from "../../hooks/useMe";
import { AtomsWrapper } from "../../src/components/atoms-wrapper";
import { useToast } from "../../src/components/ui/use-toast";
import type { Availability } from "../AvailabilitySettings";
import type { CustomClassNames } from "../AvailabilitySettings";
import type { CustomClassNames, AvailabilitySettingsFormRef } from "../AvailabilitySettings";
import { AvailabilitySettings } from "../AvailabilitySettings";
import type { AvailabilityFormValues } from "../types";
@@ -37,24 +38,28 @@ export type AvailabilitySettingsPlatformWrapperProps = {
loadingStateChildren?: ReactNode;
};
export const AvailabilitySettingsPlatformWrapper = ({
id,
customClassNames,
onDeleteError,
onDeleteSuccess,
onUpdateError,
onUpdateSuccess,
disableEditableHeading = false,
enableOverrides = false,
onBeforeUpdate,
onFormStateChange,
allowDelete,
allowSetToDefault,
disableToasts,
isDryRun = false,
noScheduleChildren,
loadingStateChildren,
}: AvailabilitySettingsPlatformWrapperProps) => {
export const AvailabilitySettingsPlatformWrapper = forwardRef<
AvailabilitySettingsFormRef,
AvailabilitySettingsPlatformWrapperProps
>(function AvailabilitySettingsPlatformWrapper(props, ref) {
const {
id,
customClassNames,
onDeleteError,
onDeleteSuccess,
onUpdateError,
onUpdateSuccess,
disableEditableHeading = false,
enableOverrides = false,
onBeforeUpdate,
onFormStateChange,
allowDelete,
allowSetToDefault,
disableToasts,
isDryRun = false,
noScheduleChildren,
loadingStateChildren,
} = props;
const { isLoading, data: atomSchedule } = useAtomSchedule(id);
const { data: me } = useMe();
const { timeFormat } = me?.data || { timeFormat: null };
@@ -139,6 +144,7 @@ export const AvailabilitySettingsPlatformWrapper = ({
return (
<AtomsWrapper>
<AvailabilitySettings
ref={ref}
disableEditableHeading={disableEditableHeading}
handleDelete={() => {
if (isDryRun) {
@@ -195,4 +201,4 @@ export const AvailabilitySettingsPlatformWrapper = ({
/>
</AtomsWrapper>
);
};
});
@@ -196,16 +196,6 @@ export const useEventTypeForm = ({
// Watch all form values to trigger onFormStateChange on any change
const watchedValues = form.watch();
useEffect(() => {
if (onFormStateChange) {
onFormStateChange({
isDirty: isFormDirty,
dirtyFields: dirtyFields as Partial<FormValues>,
values: watchedValues,
});
}
}, [isFormDirty, watchedValues, onFormStateChange]);
const isObject = <T>(value: T): boolean => {
return value !== null && typeof value === "object" && !Array.isArray(value);
};
@@ -421,5 +411,15 @@ export const useEventTypeForm = ({
}
};
useEffect(() => {
if (onFormStateChange) {
onFormStateChange({
isDirty: isFormDirty,
dirtyFields: dirtyFields as Partial<FormValues>,
values: watchedValues,
});
}
}, [isFormDirty, dirtyFields, watchedValues, onFormStateChange]);
return { form, handleSubmit };
};
@@ -1,7 +1,7 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { useRef, useState, useEffect } from "react";
import { useRef, useState, useEffect, forwardRef, useImperativeHandle, useCallback } from "react";
import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
import { EventType as EventTypeComponent } from "@calcom/features/eventtypes/components/EventType";
@@ -12,7 +12,11 @@ import type { EventAvailabilityTabCustomClassNames } from "@calcom/features/even
import type { EventLimitsTabCustomClassNames } from "@calcom/features/eventtypes/components/tabs/limits/EventLimitsTab";
import type { EventRecurringTabCustomClassNames } from "@calcom/features/eventtypes/components/tabs/recurring/RecurringEventController";
import type { EventSetupTabCustomClassNames } from "@calcom/features/eventtypes/components/tabs/setup/EventSetupTab";
import type { EventTypeSetupProps, FormValues } from "@calcom/features/eventtypes/lib/types";
import type {
EventTypeSetupProps,
FormValues,
EventTypePlatformWrapperRef,
} from "@calcom/features/eventtypes/lib/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SchedulingType } from "@calcom/prisma/enums";
@@ -64,28 +68,30 @@ export type EventTypePlatformWrapperProps = {
}) => void;
};
const EventType = ({
tabs = ["setup", "availability", "team", "limits", "advanced", "recurring", "payments"],
onSuccess,
onError,
onDeleteSuccess,
onDeleteError,
id,
allowDelete = true,
customClassNames,
disableToasts = false,
isDryRun = false,
onFormStateChange,
...props
}: EventTypeSetupProps & EventTypePlatformWrapperProps) => {
const EventType = forwardRef<
EventTypePlatformWrapperRef,
EventTypeSetupProps & EventTypePlatformWrapperProps
>(function EventType(props, ref) {
const {
tabs = ["setup", "availability", "team", "limits", "advanced", "recurring", "payments"],
onSuccess,
onError,
onDeleteSuccess,
onDeleteError,
id,
allowDelete = true,
customClassNames,
disableToasts = false,
isDryRun = false,
onFormStateChange,
...restProps
} = props;
const { t } = useLocale();
const { toast } = useToast();
const { organizationId } = useAtomsContext();
const isTeamEventTypeDeleted = useRef(false);
const leaveWithoutAssigningHosts = useRef(false);
const [isOpenAssignmentWarnDialog, setIsOpenAssignmentWarnDialog] = useState<boolean>(false);
const [pendingRoute, setPendingRoute] = useState("");
const { eventType, locationOptions, team, teamMembers, destinationCalendar } = props;
const { eventType, locationOptions, team, teamMembers, destinationCalendar } = restProps;
const [slugExistsChildrenDialogOpen, setSlugExistsChildrenDialogOpen] = useState<ChildrenEventType[]>([]);
const { data: user, isLoading: isUserLoading } = useMe();
@@ -93,7 +99,6 @@ const EventType = ({
showToast(t("event_type_deleted_successfully"), "success");
isTeamEventTypeDeleted.current = true;
setSlugExistsChildrenDialogOpen([]);
setIsOpenAssignmentWarnDialog(false);
onDeleteSuccess?.();
};
@@ -158,9 +163,37 @@ const EventType = ({
},
onFormStateChange: onFormStateChange,
});
// Create a ref for the save button to trigger its click
const saveButtonRef = useRef<HTMLButtonElement>(null);
const handleFormSubmit = useCallback(() => {
if (saveButtonRef.current) {
saveButtonRef.current.click();
} else {
form.handleSubmit(handleSubmit)();
}
}, [handleSubmit, form]);
const validateForm = useCallback(async () => {
const isValid = await form.trigger();
return {
isValid,
errors: form.formState.errors,
};
}, [form]);
useImperativeHandle(
ref,
() => ({
validateForm,
handleFormSubmit,
}),
[validateForm, handleFormSubmit]
);
const slug = form.watch("slug") ?? eventType.slug;
const showToast = (message: string, variant: "success" | "warning" | "error") => {
const showToast = (message: string, _variant: "success" | "warning" | "error") => {
if (!disableToasts) {
toast({ description: message });
}
@@ -246,9 +279,8 @@ const EventType = ({
hosts: eventType.hosts,
assignAllTeamMembers: eventType.assignAllTeamMembers,
isManagedEventType: eventType.schedulingType === SchedulingType.MANAGED,
onError: (url) => {
setIsOpenAssignmentWarnDialog(true);
setPendingRoute(url);
onError: () => {
return;
},
onStart: () => {
return;
@@ -284,7 +316,7 @@ const EventType = ({
return (
<AtomsWrapper customClassName={customClassNames?.atomsWrapper}>
<EventTypeComponent
{...props}
{...restProps}
tabMap={tabMap}
onDelete={onDelete}
onConflict={onConflict}
@@ -294,7 +326,8 @@ const EventType = ({
isPlatform
tabName={currentTab}
tabsNavigation={tabsNavigation}
allowDelete={allowDelete}>
allowDelete={allowDelete}
saveButtonRef={saveButtonRef}>
<>
{slugExistsChildrenDialogOpen.length ? (
<ManagedEventTypeDialog
@@ -315,20 +348,24 @@ const EventType = ({
</EventTypeComponent>
</AtomsWrapper>
);
};
});
export const EventTypePlatformWrapper = ({
id,
tabs,
onSuccess,
onError,
onDeleteSuccess,
onDeleteError,
allowDelete = true,
customClassNames,
isDryRun,
onFormStateChange,
}: EventTypePlatformWrapperProps) => {
export const EventTypePlatformWrapper = forwardRef<
EventTypePlatformWrapperRef,
EventTypePlatformWrapperProps
>(function EventTypePlatformWrapper(props, ref) {
const {
id,
tabs,
onSuccess,
onError,
onDeleteSuccess,
onDeleteError,
allowDelete = true,
customClassNames,
isDryRun,
onFormStateChange,
} = props;
const { data: eventTypeQueryData } = useAtomsEventTypeById(id);
const queryClient = useQueryClient();
@@ -344,7 +381,7 @@ export const EventTypePlatformWrapper = ({
});
}
};
}, [queryClient, id]);
}, [queryClient, id, eventTypeQueryData]);
if (!eventTypeQueryData) return null;
@@ -361,6 +398,7 @@ export const EventTypePlatformWrapper = ({
customClassNames={customClassNames}
isDryRun={isDryRun}
onFormStateChange={onFormStateChange}
ref={ref}
/>
);
};
});
@@ -1,3 +1,6 @@
import type { TabMap } from "@calcom/features/eventtypes/lib/types";
import type { EventTypePlatformWrapperRef as EventSettingsFromRef } from "@calcom/features/eventtypes/lib/types";
export type { EventSettingsFromRef };
export type PlatformTabs = keyof Omit<TabMap, "workflows" | "webhooks" | "instant" | "ai" | "apps">;
+2
View File
@@ -29,6 +29,8 @@ export { DestinationCalendarSettingsPlatformWrapper as DestinationCalendarSettin
export { CalendarSettingsPlatformWrapper as CalendarSettings } from "./calendar-settings/index";
export type { UpdateScheduleInput_2024_06_11 as UpdateScheduleBody } from "@calcom/platform-types";
export { EventTypePlatformWrapper as EventTypeSettings } from "./event-types/wrappers/EventTypePlatformWrapper";
export type { EventSettingsFromRef } from "./event-types/wrappers/types";
export type { AvailabilitySettingsFormRef } from "./availability/types";
export { ConferencingAppsViewPlatformWrapper as ConferencingAppsSettings } from "./connect/conferencing-apps/ConferencingAppsViewPlatformWrapper";
export { StripeConnect } from "./connect/stripe/StripeConnect";
export { CreateEventTypePlatformWrapper as CreateEventType } from "./event-types/wrappers/CreateEventTypePlatformWrapper";
+4 -2
View File
@@ -43,7 +43,8 @@
"exports": {
".": {
"import": "./dist/cal-atoms.js",
"types": "./dist/index.d.ts"
"types": "./dist/index.d.ts",
"require": "./dist/cal-atoms.umd.cjs"
},
"./add-members-switch/AddMembersWithSwitchPlatformWrapper": "./add-members-switch/AddMembersWithSwitchPlatformWrapper.tsx",
"./add-members-switch/AddMembersWithSwitchWebWrapper": "./add-members-switch/AddMembersWithSwitchWebWrapper.tsx",
@@ -84,5 +85,6 @@
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"typescript": "^5.0.0"
}
},
"main": "./dist/cal-atoms.umd.cjs"
}
@@ -1,16 +1,49 @@
import { Navbar } from "@/components/Navbar";
import { Inter } from "next/font/google";
import { useRef, useCallback } from "react";
import type { AvailabilitySettingsFormRef } from "@calcom/atoms";
import { AvailabilitySettings } from "@calcom/atoms";
const inter = Inter({ subsets: ["latin"] });
export default function Availability(props: { calUsername: string; calEmail: string }) {
const availabilityRef = useRef<AvailabilitySettingsFormRef>(null);
const handleFormStateChange = useCallback((formState: unknown) => {
console.log(formState, "formStateeeeee");
}, []);
const handleValidate = async () => {
const result = await availabilityRef.current?.validateForm();
console.log("Validation result:", result);
};
const handleSubmit = () => {
availabilityRef.current?.handleFormSubmit();
};
return (
<main className={`flex min-h-screen flex-col ${inter.className}`}>
<Navbar username={props.calUsername} />
<div>
<h1 className="mx-10 my-4 text-2xl font-semibold">Availability Settings</h1>
<div className="mx-10 mb-4 flex gap-4">
<button
onClick={handleValidate}
className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
Validate Form
</button>
<button
onClick={handleSubmit}
className="rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
Submit Form
</button>
</div>
<AvailabilitySettings
ref={availabilityRef}
enableOverrides={true}
customClassNames={{
subtitlesClassName: "text-red-500",
@@ -18,6 +51,7 @@ export default function Availability(props: { calUsername: string; calEmail: str
editableHeadingClassName: "underline font-semibold",
hiddenSwitchClassname: { thumb: "bg-red-500" },
}}
onFormStateChange={handleFormStateChange}
onUpdateSuccess={() => {
console.log("Updated successfully");
}}
@@ -2,8 +2,9 @@ import { Navbar } from "@/components/Navbar";
import { Inter } from "next/font/google";
// eslint-disable-next-line @calcom/eslint/deprecated-imports-next-router
import { useRouter } from "next/router";
import { useState } from "react";
import { useState, useRef, useCallback } from "react";
import type { EventSettingsFromRef } from "@calcom/atoms";
import {
useEventTypes,
useTeamEventTypes,
@@ -18,6 +19,20 @@ export default function Bookings(props: { calUsername: string; calEmail: string
const [eventTypeId, setEventTypeId] = useState<number | null>(null);
const [isTeamEvent, setIsTeamEvent] = useState<boolean>(false);
const router = useRouter();
const eventTypeRef = useRef<EventSettingsFromRef>(null);
const handleFormStateChange = useCallback((formState: unknown) => {
console.log(formState, "formStateeeeee");
}, []);
const handleValidate = async () => {
const result = await eventTypeRef.current?.validateForm();
console.log("Validation result:", result);
};
const handleSubmit = () => {
eventTypeRef.current?.handleFormSubmit();
};
const { isLoading: isLoadingEvents, data: eventTypes, refetch } = useEventTypes(props.calUsername);
const { data: teams } = useTeams();
const {
@@ -739,7 +754,22 @@ export default function Bookings(props: { calUsername: string; calEmail: string
setEventTypeId(null);
}}
onDeleteError={console.error}
onFormStateChange={handleFormStateChange}
ref={eventTypeRef}
/>
<div className="mt-4 flex justify-center gap-4">
<button
onClick={handleValidate}
className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600">
Validate Form
</button>
<button
onClick={handleSubmit}
className="rounded bg-green-500 px-4 py-2 text-white hover:bg-green-600">
Submit Form
</button>
</div>
</div>
)}