diff --git a/src/Form/components/Form.tsx b/src/Form/components/Form.tsx new file mode 100644 index 0000000..4c7c047 --- /dev/null +++ b/src/Form/components/Form.tsx @@ -0,0 +1,38 @@ +import React, { type ReactNode } from 'react' +import { Card, Stack, LoadingOverlay } from '@mantine/core' +import { FormSection } from './FormSection' + +interface FormProps { + children: ReactNode + loading?: boolean + [key: string]: any +} + +export const Form: React.FC & { + Section: typeof FormSection +} = ({ children, loading, ...others }) => { + return ( + + + {children} + + ) +} + +Form.Section = FormSection diff --git a/src/Form/components/FormLayout.tsx b/src/Form/components/FormLayout.tsx new file mode 100644 index 0000000..3fe1c74 --- /dev/null +++ b/src/Form/components/FormLayout.tsx @@ -0,0 +1,55 @@ +import React, { type ReactNode } from 'react' +import { Modal } from '@mantine/core' +import { Form } from './Form' +import { FormLayoutStoreProvider, useFormLayoutStore } from '../store/FormLayout.store' +import type { RequestType } from '../types' + +interface FormLayoutProps { + children: ReactNode + dirty?: boolean + loading?: boolean + onCancel?: () => void + onSubmit?: () => void + request?: RequestType + modal?: boolean + modalProps?: any + nested?: boolean + deleteFormProps?: any + [key: string]: any +} + +const LayoutComponent: React.FC = ({ + children, + modal, + modalProps, + ...others +}) => { + const { request } = useFormLayoutStore((state) => ({ + request: state.request, + })) + + const modalWidth = request === 'delete' ? 400 : modalProps?.width + + return modal === true ? ( + modalProps?.onClose?.()} + opened={modalProps?.opened || false} + size="auto" + withCloseButton={false} + centered={request !== 'delete'} + {...modalProps} + > +
+
{children}
+
+
+ ) : ( +
{children}
+ ) +} + +export const FormLayout: React.FC = (props) => ( + + + +) diff --git a/src/Form/components/FormSection.tsx b/src/Form/components/FormSection.tsx new file mode 100644 index 0000000..7fa9368 --- /dev/null +++ b/src/Form/components/FormSection.tsx @@ -0,0 +1,117 @@ +import React, { type ReactNode } from 'react' +import { Stack, Group, Paper, Button, Title, Box } from '@mantine/core' +import { useFormLayoutStore } from '../store/FormLayout.store' + +interface FormSectionProps { + type: 'header' | 'body' | 'footer' | 'error' + title?: string + rightSection?: ReactNode + children?: ReactNode + buttonTitles?: { submit?: string; cancel?: string } + className?: string + [key: string]: any +} + +export const FormSection: React.FC = ({ + type, + title, + rightSection, + children, + buttonTitles, + className, + ...others +}) => { + const { onCancel, onSubmit, request, loading } = useFormLayoutStore((state) => ({ + onCancel: state.onCancel, + onSubmit: state.onSubmit, + request: state.request, + loading: state.loading, + })) + + if (type === 'header') { + return ( + + + {title} + + {rightSection && {rightSection}} + + ) + } + + if (type === 'body') { + return ( + + {children} + + ) + } + + if (type === 'footer') { + return ( + + {children} + {request !== 'view' && ( + <> + + + + )} + + ) + } + + if (type === 'error') { + return ( + + {children} + + ) + } + + return null +} diff --git a/src/Form/components/SuperForm.tsx b/src/Form/components/SuperForm.tsx new file mode 100644 index 0000000..935a8f7 --- /dev/null +++ b/src/Form/components/SuperForm.tsx @@ -0,0 +1,34 @@ +import React, { forwardRef, type ReactElement, type Ref } from 'react' +import { FormProvider, useForm, type FieldValues } from 'react-hook-form' +import { Provider } from '../store/SuperForm.store' +import type { SuperFormProps, SuperFormRef } from '../types' +import Layout from './SuperFormLayout' +import SuperFormPersist from './SuperFormPersist' + +const SuperForm = ( + { useFormProps, gridRef, children, persist, ...others }: SuperFormProps, + ref +) => { + const form = useForm({ ...useFormProps }) + + return ( + + + {persist && ( + + )} + gridRef={gridRef} ref={ref}> + {children} + + + + ) +} + +const FRSuperForm = forwardRef(SuperForm) as ( + props: SuperFormProps & { + ref?: Ref> + } +) => ReactElement + +export default FRSuperForm diff --git a/src/Form/components/SuperFormLayout.tsx b/src/Form/components/SuperFormLayout.tsx new file mode 100644 index 0000000..51dc6a2 --- /dev/null +++ b/src/Form/components/SuperFormLayout.tsx @@ -0,0 +1,364 @@ +import React, { + forwardRef, + RefObject, + useEffect, + useImperativeHandle, + useMemo, + type MutableRefObject, + type ReactElement, + type ReactNode, + type Ref, +} from 'react' +import { useFormContext, useFormState, type FieldValues, type UseFormReturn } from 'react-hook-form' +import { v4 as uuid } from 'uuid' +import { + ActionIcon, + Group, + List, + LoadingOverlay, + Paper, + Spoiler, + Stack, + Title, + Tooltip, + Transition, +} from '@mantine/core' +import { IconChevronsLeft, IconChevronsRight } from '@tabler/icons-react' +import { useUncontrolled } from '@mantine/hooks' +import useRemote from '../hooks/useRemote' +import { useStore } from '../store/SuperForm.store' +import classes from '../styles/Form.module.css' +import { Form } from './Form' +import { FormLayout } from './FormLayout' +import type { GridRef, SuperFormRef } from '../types' + +const SuperFormLayout = ( + { + children, + gridRef, + }: { + children: React.ReactNode | ((props: UseFormReturn) => React.ReactNode) + gridRef?: MutableRefObject | null> + }, + ref +) => { + // Component store State + const { + layoutProps, + meta, + nested, + onBeforeSubmit, + onCancel, + onLayoutMounted, + onLayoutUnMounted, + onResetForm, + onSubmit, + primeData, + request, + tableName, + value, + } = useStore((state) => ({ + extraButtons: state.extraButtons, + layoutProps: state.layoutProps, + meta: state.meta, + nested: state.nested, + onBeforeSubmit: state.onBeforeSubmit, + onCancel: state.onCancel, + onLayoutMounted: state.onLayoutMounted, + onLayoutUnMounted: state.onLayoutUnMounted, + onResetForm: state.onResetForm, + onSubmit: state.onSubmit, + primeData: state.primeData, + request: state.request, + tableName: state.remote?.tableName, + value: state.value, + })) + + const [_opened, _setOpened] = useUncontrolled({ + value: layoutProps?.bodyRightSection?.opened, + defaultValue: false, + onChange: layoutProps?.bodyRightSection?.setOpened, + }) + + // Component Hooks + const form = useFormContext() + const formState = useFormState({ control: form.control }) + + const { isFetching, mutateAsync, error, queryKey } = useRemote(gridRef) + + // Component variables + const formUID = useMemo(() => { + return meta?.id ?? uuid() + }, []) + + const requestString = request?.charAt(0).toUpperCase() + request?.slice(1) + + const renderRightSection = ( + <> + + _setOpened(!_opened)} + radius='6' + m={2} + > + {_opened ? : } + + + + + {typeof children === 'function' ? children({ ...form }) : children} + + + {(transitionStyles) => ( + + {layoutProps?.bodyRightSection?.render?.({ + form, + formValue: form.getValues(), + isFetching, + opened: _opened, + queryKey, + setOpened: _setOpened, + })} + + )} + + + + ) + + // Component Callback Functions + const onFormSubmit = async (data: T | any, closeForm: boolean = true) => { + const res: any = + typeof onBeforeSubmit === 'function' + ? await mutateAsync?.(await onBeforeSubmit(data, request, form)) + : await mutateAsync?.(data) + + if ((tableName?.length ?? 0) > 0) { + if (res?.ok || (res?.status >= 200 && res?.status < 300)) { + onSubmit?.(res?.data, request, data, form, closeForm) + } else { + form.setError('root', { + message: res.status === 401 ? 'Username or password is incorrect' : res?.error, + }) + } + } else { + onSubmit?.(data, request, data, form, closeForm) + } + } + + // Component use Effects + useEffect(() => { + if (request === 'insert') { + if (onResetForm) { + onResetForm(primeData, form).then((resetData) => { + form.reset(resetData) + }) + } else { + form.reset(primeData) + } + } else if ((request === 'change' || request === 'delete') && (tableName?.length ?? 0) === 0) { + if (onResetForm) { + onResetForm(value, form).then((resetData) => { + form.reset(resetData) + }) + } else { + form.reset(value) + } + } + onLayoutMounted?.() + return onLayoutUnMounted + }, [ + request, + primeData, + tableName, + value, + form.reset, + onResetForm, + onLayoutMounted, + onLayoutUnMounted, + ]) + + useEffect(() => { + if ( + (Object.keys(formState.errors)?.length > 0 || error) && + _opened === false && + layoutProps?.showErrorList !== false + ) { + _setOpened(true) + } + }, [Object.keys(formState.errors)?.length > 0, error, layoutProps?.showErrorList]) + + useImperativeHandle, SuperFormRef>(ref, () => ({ + form, + mutation: { isFetching, mutateAsync, error }, + submit: (closeForm: boolean = true, afterSubmit?: (data: T | any) => void) => { + return form.handleSubmit(async (data: T | any) => { + await onFormSubmit(data, closeForm) + afterSubmit?.(data) + })() + }, + queryKey, + getFormState: () => formState, + })) + + return ( +
{ + e.stopPropagation() + e.preventDefault() + form.handleSubmit((data: T | any) => { + onFormSubmit(data) + })(e) + }} + style={{ height: '100%' }} + className={request === 'view' ? classes.disabled : ''} + > + {/* */} + {layoutProps?.noLayout ? ( + typeof layoutProps?.bodyRightSection?.render === 'function' ? ( + renderRightSection + ) : typeof children === 'function' ? ( + <> + + {children({ ...form })} + + ) : ( + <> + + {children} + + ) + ) : ( + onCancel?.(request)} + onSubmit={form.handleSubmit((data: T | any) => { + onFormSubmit(data) + })} + request={request} + modal={false} + nested={nested} + > + {!layoutProps?.noHeader && ( + + )} + {(Object.keys(formState.errors)?.length > 0 || error) && ( + + + {(error?.message?.length ?? 0) > 0 + ? 'Server Error' + : 'Required information is incomplete*'} + + {(error as any)?.response?.data?.msg || + (error as any)?.response?.data?._error || + error?.message} + {layoutProps?.showErrorList !== false && ( + + + {getErrorMessages(formState.errors)} + + + )} + + )} + {typeof layoutProps?.bodyRightSection?.render === 'function' ? ( + + {renderRightSection} + + ) : ( + + {typeof children === 'function' ? children({ ...form }) : children} + + )} + {!layoutProps?.noFooter && ( + >) + : layoutProps?.footerSectionProps)} + > + {typeof layoutProps?.extraButtons === 'function' + ? layoutProps?.extraButtons(form) + : layoutProps?.extraButtons} + + )} + + )} + + ) +} + +const getErrorMessages = (errors: any): ReactNode | null => { + return Object.keys(errors ?? {}).map((key) => { + if (typeof errors[key] === 'object' && key !== 'ref') { + return getErrorMessages(errors[key]) + } + if (key !== 'message') { + return null + } + + return {errors[key]} + }) +} + +const FRSuperFormLayout = forwardRef(SuperFormLayout) as ( + props: { + children: React.ReactNode | ((props: UseFormReturn) => React.ReactNode) + gridRef?: MutableRefObject + } & { + ref?: Ref> + } +) => ReactElement + +export default FRSuperFormLayout diff --git a/src/Form/components/SuperFormPersist.tsx b/src/Form/components/SuperFormPersist.tsx new file mode 100644 index 0000000..8026511 --- /dev/null +++ b/src/Form/components/SuperFormPersist.tsx @@ -0,0 +1,66 @@ +import { useEffect, useState } from 'react' +import { useFormContext, useFormState } from 'react-hook-form' +import { useDebouncedCallback } from '@mantine/hooks' +import useSubscribe from '../hooks/use-subscribe' +import { useSuperFormStore } from '../store/SuperForm.store' +import { openConfirmModal } from '../utils/openConfirmModal' + +const SuperFormPersist = ({ storageKey }: { storageKey?: string | null }) => { + // Component store State + const [persistKey, setPersistKey] = useState('') + const { isDirty, isReady, isSubmitted } = useFormState() + + const { remote, request } = useSuperFormStore((state) => ({ + request: state.request, + remote: state.remote, + })) + + // Component Hooks + const { reset, setValue } = useFormContext() + + const handleFormChange = useDebouncedCallback(({ values }) => { + setPersistKey(() => { + const key = `superform-persist-${storageKey?.length > 0 ? storageKey : `${remote?.tableName || 'local'}-${request}-${values[remote?.primaryKey] ?? ''}`}` + + if (!isDirty) { + return key + } + + window.localStorage.setItem(key, JSON.stringify(values)) + + return key + }) + }, 250) + + useSubscribe('', handleFormChange) + + // Component use Effects + useEffect(() => { + if (isReady && persistKey) { + const data = window.localStorage.getItem(persistKey) + if (!data) { + return + } + + if (isSubmitted) { + window.localStorage.removeItem(persistKey) + return + } + + openConfirmModal( + () => { + reset(JSON.parse(data)) + setValue('_dirty', true, { shouldDirty: true }) + }, + () => { + window.localStorage.removeItem(persistKey) + }, + 'Do you want to restore the previous data that was not submitted?' + ) + } + }, [isReady, isSubmitted, persistKey]) + + return null +} + +export default SuperFormPersist diff --git a/src/Form/config/ApiConfig.tsx b/src/Form/config/ApiConfig.tsx new file mode 100644 index 0000000..588898b --- /dev/null +++ b/src/Form/config/ApiConfig.tsx @@ -0,0 +1,43 @@ +import React, { createContext, useContext, ReactNode, useState } from 'react' + +interface ApiConfigContextValue { + apiURL: string + setApiURL: (url: string) => void +} + +const ApiConfigContext = createContext(null) + +interface ApiConfigProviderProps { + children: ReactNode + defaultApiURL?: string +} + +export const ApiConfigProvider: React.FC = ({ + children, + defaultApiURL = '', +}) => { + const [apiURL, setApiURL] = useState(defaultApiURL) + + return ( + + {children} + + ) +} + +export const useApiConfig = (): ApiConfigContextValue => { + const context = useContext(ApiConfigContext) + if (!context) { + throw new Error('useApiConfig must be used within ApiConfigProvider') + } + return context +} + +/** + * Hook to get API URL with optional override + * @param overrideURL - Optional URL to use instead of context value + */ +export const useApiURL = (overrideURL?: string): string => { + const { apiURL } = useApiConfig() + return overrideURL ?? apiURL +} diff --git a/src/Form/containers/Drawer/SuperFormDrawer.tsx b/src/Form/containers/Drawer/SuperFormDrawer.tsx new file mode 100644 index 0000000..111ad82 --- /dev/null +++ b/src/Form/containers/Drawer/SuperFormDrawer.tsx @@ -0,0 +1,146 @@ +import React, { + forwardRef, + useCallback, + useImperativeHandle, + useRef, + useState, + type ReactElement, + type Ref, +} from 'react' +import { IconX } from '@tabler/icons-react' +import type { FieldValues } from 'react-hook-form' +import { ActionIcon, Drawer } from '@mantine/core' +import type { SuperFormDrawerProps, SuperFormDrawerRef, SuperFormRef } from '../../types' +import SuperForm from '../../components/SuperForm' +import { openConfirmModal } from '../../utils/openConfirmModal' + +const SuperFormDrawer = ( + { drawerProps, noCloseOnSubmit, ...formProps }: SuperFormDrawerProps, + ref: Ref> +) => { + // Component Refs + const formRef = useRef>(null) + const drawerRef = useRef(null) + + // Component store State + // Tell drawer that form layout mounted to fix refs + const [layoutMounted, setLayoutMounted] = useState(false) + + // Component Callback Functions + const onSubmit = (data: T, request, formData, form, closeForm: boolean = true) => { + formProps?.onSubmit?.(data, request, formData, form, closeForm) + + if (request === 'delete') { + drawerProps?.onClose() + } + + if (!noCloseOnSubmit) { + if (closeForm) { + drawerProps?.onClose() + } + } + } + + const onCancel = (request) => { + if (formRef?.current?.getFormState().isDirty) { + openConfirmModal(() => { + drawerProps?.onClose() + formProps?.onCancel?.(request) + }) + } else { + drawerProps?.onClose() + formProps?.onCancel?.(request) + } + } + + const onLayoutMounted = useCallback(() => { + setLayoutMounted(true) + formProps?.onLayoutMounted?.() + }, [formProps?.onLayoutMounted]) + + const onLayoutUnMounted = useCallback(() => { + setLayoutMounted(false) + formProps?.onLayoutUnMounted?.() + }, [formProps?.onLayoutUnMounted]) + + // Component use Effects + useImperativeHandle, SuperFormDrawerRef>( + ref, + () => ({ + ...formRef.current, + drawer: drawerRef.current, + } as SuperFormDrawerRef), + [layoutMounted] + ) + + return ( + { + if (e.key === 'Escape' && drawerProps.closeOnEscape !== false) { + e.stopPropagation() + onCancel(formProps.request) + } + }} + overlayProps={{ backgroundOpacity: 0.5, blur: 0.5 }} + padding={6} + position='right' + transitionProps={{ + transition: 'slide-left', + duration: 150, + timingFunction: 'linear', + }} + size={500} + styles={{ + content: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'stretch', + }, + body: { + minHeight: '100px', + flexGrow: 1, + }, + }} + keepMounted={false} + {...drawerProps} + closeOnEscape={false} + withCloseButton={false} + title={null} + > + + {...formProps} + onCancel={onCancel} + onSubmit={onSubmit} + onLayoutMounted={onLayoutMounted} + onLayoutUnMounted={onLayoutUnMounted} + ref={formRef} + layoutProps={{ + ...formProps?.layoutProps, + rightSection: ( + { + onCancel(formProps?.request) + }} + > + + + ), + title: + (drawerProps.title as string) ?? + formProps?.layoutProps?.title ?? + (formProps?.request as string), + }} + /> + + ) +} + +const FRSuperFormDrawer = forwardRef(SuperFormDrawer) as ( + props: SuperFormDrawerProps & { ref?: Ref> } +) => ReactElement + +export default FRSuperFormDrawer diff --git a/src/Form/containers/Modal/SuperFormModal.tsx b/src/Form/containers/Modal/SuperFormModal.tsx new file mode 100644 index 0000000..46bd768 --- /dev/null +++ b/src/Form/containers/Modal/SuperFormModal.tsx @@ -0,0 +1,105 @@ +import React, { + forwardRef, + useCallback, + useImperativeHandle, + useRef, + useState, + type ReactElement, + type Ref, +} from 'react' +import type { FieldValues } from 'react-hook-form' +import { Modal, ScrollArea } from '@mantine/core' +import type { SuperFormModalProps, SuperFormModalRef, SuperFormRef } from '../../types' +import SuperForm from '../../components/SuperForm' +import { openConfirmModal } from '../../utils/openConfirmModal' + +const SuperFormModal = ( + { modalProps, noCloseOnSubmit, ...formProps }: SuperFormModalProps, + ref: Ref> +) => { + // Component Refs + const modalRef = useRef(null) + const formRef = useRef>(null) + + // Component store State + // Tell drawer that form layout mounted to fix refs + const [layoutMounted, setLayoutMounted] = useState(false) + + // Component Callback Functions + const onSubmit = (data: T, request, formData, form, closeForm: boolean = true) => { + formProps?.onSubmit?.(data, request, formData, form, closeForm) + + if (request === 'delete') { + modalProps?.onClose() + } + + if (!noCloseOnSubmit) { + if (closeForm) { + modalProps?.onClose() + } + } + } + + const onCancel = (request) => { + if (formRef?.current?.getFormState().isDirty) { + openConfirmModal(() => { + modalProps?.onClose() + formProps?.onCancel?.(request) + }) + } else { + modalProps?.onClose() + formProps?.onCancel?.(request) + } + } + + const onLayoutMounted = useCallback(() => { + setLayoutMounted(true) + formProps?.onLayoutMounted?.() + }, [formProps?.onLayoutMounted]) + + const onLayoutUnMounted = useCallback(() => { + setLayoutMounted(false) + formProps?.onLayoutUnMounted?.() + }, [formProps?.onLayoutUnMounted]) + + // Component use Effects + useImperativeHandle, SuperFormModalRef>( + ref, + () => ({ + ...formRef.current, + modal: modalRef.current, + } as SuperFormModalRef), + [layoutMounted] + ) + + return ( + + + {...formProps} + onCancel={onCancel} + onSubmit={onSubmit} + onLayoutMounted={onLayoutMounted} + onLayoutUnMounted={onLayoutUnMounted} + ref={formRef} + /> + + ) +} + +const FRSuperFormModal = forwardRef(SuperFormModal) as ( + props: SuperFormModalProps & { ref?: Ref> } +) => ReactElement + +export default FRSuperFormModal diff --git a/src/Form/containers/Popover/SuperFormPopover.tsx b/src/Form/containers/Popover/SuperFormPopover.tsx new file mode 100644 index 0000000..264af05 --- /dev/null +++ b/src/Form/containers/Popover/SuperFormPopover.tsx @@ -0,0 +1,116 @@ +import React, { + forwardRef, + useCallback, + useImperativeHandle, + useRef, + useState, + type ReactElement, + type Ref, +} from 'react' +import type { FieldValues } from 'react-hook-form' +import { Box, Popover } from '@mantine/core' +import { useUncontrolled } from '@mantine/hooks' +import type { SuperFormPopoverProps, SuperFormPopoverRef, SuperFormRef } from '../../types' +import SuperForm from '../../components/SuperForm' +import { openConfirmModal } from '../../utils/openConfirmModal' + +const SuperFormPopover = ( + { popoverProps, target, noCloseOnSubmit, ...formProps }: SuperFormPopoverProps, + ref: Ref> +) => { + // Component Refs + const popoverRef = useRef(null) + const formRef = useRef>(null) + + // Component store State + // Tell drawer that form layout mounted to fix refs + const [layoutMounted, setLayoutMounted] = useState(false) + + // Component Hooks + const [_value, _onChange] = useUncontrolled({ + value: popoverProps?.opened, + onChange: popoverProps?.onChange, + }) + + // Component Callback Functions + const onSubmit = (data: T, request, formData, form, closeForm: boolean = true) => { + formProps?.onSubmit?.(data, request, formData, form, closeForm) + + if (request === 'delete') { + _onChange(false) + } + + if (!noCloseOnSubmit) { + if (closeForm) { + _onChange(false) + } + } + } + + const onCancel = (request) => { + if (formRef?.current?.getFormState().isDirty) { + openConfirmModal(() => { + _onChange(false) + formProps?.onCancel?.(request) + }) + } else { + _onChange(false) + formProps?.onCancel?.(request) + } + } + + const onLayoutMounted = useCallback(() => { + setLayoutMounted(true) + formProps?.onLayoutMounted?.() + }, [formProps?.onLayoutMounted]) + + const onLayoutUnMounted = useCallback(() => { + setLayoutMounted(false) + formProps?.onLayoutUnMounted?.() + }, [formProps?.onLayoutUnMounted]) + + // Component use Effects + useImperativeHandle, SuperFormPopoverRef>( + ref, + () => ({ + ...formRef.current, + popover: popoverRef.current, + } as SuperFormPopoverRef), + [layoutMounted] + ) + + return ( + _onChange(false)} + opened={_value} + position='left' + radius='md' + withArrow + withinPortal + zIndex={200} + keepMounted={false} + {...popoverProps} + > + + _onChange(true)}>{target} + + + + + + ) +} + +const FRSuperFormPopover = forwardRef(SuperFormPopover) as ( + props: SuperFormPopoverProps & { ref?: Ref> } +) => ReactElement + +export default FRSuperFormPopover diff --git a/src/Form/hooks/use-drawer-form-state.tsx b/src/Form/hooks/use-drawer-form-state.tsx new file mode 100644 index 0000000..57dfbac --- /dev/null +++ b/src/Form/hooks/use-drawer-form-state.tsx @@ -0,0 +1,96 @@ +import { useState } from 'react' +import { FieldValues } from 'react-hook-form' +import { SuperFormProps, RequestType, ExtendedDrawerProps } from '../types' + +interface UseDrawerFormState extends Partial> { + drawerProps: Partial + opened?: boolean + onClose?: () => void + request: RequestType + [key: string]: any +} + +type AskFunction = (request: RequestType, buffer: any) => void + +const useDrawerFormState = ( + props?: Partial> +): { + formProps: UseDrawerFormState + setFormProps: React.Dispatch>> + open: (props: Partial>) => void + close: () => void + ask: AskFunction +} => { + const [formProps, setFormProps] = useState>({ + opened: false, + request: 'insert', + ...props, + onClose: () => + setFormProps((curr) => ({ + ...curr, + opened: false, + drawerProps: { ...curr.drawerProps, opened: false }, + })), + drawerProps: { opened: false, onClose: () => {}, ...props?.drawerProps }, + }) + + return { + formProps, + setFormProps, + open: (props?: Partial>) => { + setFormProps((curr) => { + return { + ...curr, + ...props, + request: props.request ?? curr.request, + opened: true, + drawerProps: { + ...curr.drawerProps, + ...props?.drawerProps, + opened: true, + onClose: curr.onClose, + }, + primeData: props?.primeData, + useFormProps: { + ...curr.useFormProps, + ...props?.useFormProps, + }, + layoutProps: { + ...curr.layoutProps, + ...props?.layoutProps, + }, + useQueryOptions: { + ...curr.useQueryOptions, + ...props?.useQueryOptions, + }, + meta: { + ...curr.meta, + ...props?.meta, + }, + useMutationOptions: { + ...curr.useMutationOptions, + ...props?.useMutationOptions, + }, + } + }) + }, + close: () => + setFormProps((curr) => ({ + ...curr, + opened: false, + drawerProps: { ...curr.drawerProps, opened: false, onClose: curr.onClose }, + })), + ask: (request: RequestType, buffer: any) => { + setFormProps((curr) => ({ + ...curr, + request, + value: buffer, + opened: true, + drawerProps: { ...curr.drawerProps, opened: true, onClose: curr.onClose }, + })) + }, + } +} + +export default useDrawerFormState +export type { UseDrawerFormState } diff --git a/src/Form/hooks/use-modal-form-state.tsx b/src/Form/hooks/use-modal-form-state.tsx new file mode 100644 index 0000000..9156aa8 --- /dev/null +++ b/src/Form/hooks/use-modal-form-state.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react' +import { FieldValues } from 'react-hook-form' +import { ModalProps } from '@mantine/core' +import { SuperFormProps, RequestType } from '../types' + +interface UseModalFormState extends Partial> { + modalProps: ModalProps + opened?: boolean + onClose?: () => void + request: RequestType + [key: string]: any +} + +type AskFunction = (request: RequestType, buffer: any) => void + +const useModalFormState = ( + props?: Partial> +): { + formProps: UseModalFormState + setFormProps: React.Dispatch>> + open: (props: Partial>) => void + close: () => void + ask: AskFunction +} => { + const [formProps, setFormProps] = useState>({ + opened: false, + request: 'insert', + ...props, + onClose: () => + setFormProps((curr) => ({ + ...curr, + opened: false, + modalProps: { ...curr.modalProps, opened: false }, + })), + modalProps: { opened: false, onClose: () => {}, ...props?.modalProps }, + }) + + return { + formProps, + setFormProps, + open: (props?: Partial>) => { + setFormProps((curr) => { + return { + ...curr, + ...props, + request: props.request ?? curr.request, + opened: true, + modalProps: { + ...curr.modalProps, + ...props?.modalProps, + opened: true, + onClose: curr.onClose, + }, + primeData: props?.primeData, + useFormProps: { + ...curr.useFormProps, + ...props?.useFormProps, + }, + layoutProps: { + ...curr.layoutProps, + ...props?.layoutProps, + }, + useQueryOptions: { + ...curr.useQueryOptions, + ...props?.useQueryOptions, + }, + meta: { + ...curr.meta, + ...props?.meta, + }, + useMutationOptions: { + ...curr.useMutationOptions, + ...props?.useMutationOptions, + }, + } + }) + }, + close: () => + setFormProps((curr) => ({ + ...curr, + opened: false, + modalProps: { ...curr.modalProps, opened: false, onClose: curr.onClose }, + })), + ask: (request: RequestType, buffer: any) => { + setFormProps((curr) => ({ + ...curr, + request, + value: buffer, + opened: true, + modalProps: { ...curr.modalProps, opened: true, onClose: curr.onClose }, + })) + }, + } +} + +export default useModalFormState +export type { UseModalFormState } diff --git a/src/Form/hooks/use-popover-form-state.tsx b/src/Form/hooks/use-popover-form-state.tsx new file mode 100644 index 0000000..6b86607 --- /dev/null +++ b/src/Form/hooks/use-popover-form-state.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react' +import { FieldValues } from 'react-hook-form' +import { PopoverProps } from '@mantine/core' +import { SuperFormProps, RequestType } from '../types' + +interface UsePopoverFormState extends Partial> { + popoverProps: Omit + opened?: boolean + onClose?: () => void + request: RequestType + [key: string]: any +} + +type AskFunction = (request: RequestType, buffer: any) => void + +const usePopoverFormState = ( + props?: Partial> +): { + formProps: UsePopoverFormState + setFormProps: React.Dispatch>> + open: (props: Partial>) => void + close: () => void + ask: AskFunction +} => { + const [formProps, setFormProps] = useState>({ + opened: false, + request: 'insert', + ...props, + popoverProps: { opened: false, onClose: () => {}, ...props?.popoverProps }, + onClose: () => + setFormProps((curr) => ({ + ...curr, + opened: false, + popoverProps: { ...curr.popoverProps, opened: false }, + })), + }) + + return { + formProps, + setFormProps, + open: (props?: Partial>) => { + setFormProps((curr) => { + return { + ...curr, + ...props, + request: props.request ?? curr.request, + opened: true, + popoverProps: { + ...curr.popoverProps, + ...props?.popoverProps, + opened: true, + onClose: curr.onClose, + }, + primeData: props?.primeData, + useFormProps: { + ...curr.useFormProps, + ...props?.useFormProps, + }, + layoutProps: { + ...curr.layoutProps, + ...props?.layoutProps, + }, + useQueryOptions: { + ...curr.useQueryOptions, + ...props?.useQueryOptions, + }, + meta: { + ...curr.meta, + ...props?.meta, + }, + useMutationOptions: { + ...curr.useMutationOptions, + ...props?.useMutationOptions, + }, + } + }) + }, + close: () => + setFormProps((curr) => ({ + ...curr, + opened: false, + popoverProps: { ...curr.popoverProps, opened: false, onClose: curr.onClose }, + })), + ask: (request: RequestType, buffer: any) => { + setFormProps((curr) => ({ + ...curr, + request, + value: buffer, + opened: true, + popoverProps: { ...curr.popoverProps, opened: true, onClose: curr.onClose }, + })) + }, + } +} + +export default usePopoverFormState +export type { UsePopoverFormState } diff --git a/src/Form/hooks/use-subscribe.tsx b/src/Form/hooks/use-subscribe.tsx new file mode 100644 index 0000000..12dec90 --- /dev/null +++ b/src/Form/hooks/use-subscribe.tsx @@ -0,0 +1,40 @@ +import { useEffect } from 'react' +import { + EventType, + FieldValues, + FormState, + InternalFieldName, + Path, + ReadFormState, + useFormContext, + UseFormReturn, +} from 'react-hook-form' + +const useSubscribe = ( + name: Path | readonly Path[] | undefined, + callback: ( + data: Partial> & { + values: FieldValues + name?: InternalFieldName + type?: EventType + }, + form?: UseFormReturn + ) => void, + formState?: ReadFormState, + deps?: unknown[] +) => { + const form = useFormContext() + + return useEffect(() => { + const unsubscribe = form.subscribe({ + name, + callback: (data) => callback(data, form), + formState: { values: true, ...formState }, + exact: true, + }) + + return unsubscribe + }, [form.subscribe, ...(deps || [])]) +} + +export default useSubscribe diff --git a/src/Form/hooks/use-super-form-state.tsx b/src/Form/hooks/use-super-form-state.tsx new file mode 100644 index 0000000..90b6e25 --- /dev/null +++ b/src/Form/hooks/use-super-form-state.tsx @@ -0,0 +1,38 @@ +import { useState } from 'react' +import { FieldValues } from 'react-hook-form' +import { SuperFormProps, RequestType } from '../types' + +interface UseSuperFormState extends Partial> { + request: RequestType + [key: string]: any +} + +type AskFunction = (request: RequestType, buffer: any) => void + +const useSuperFormState = ( + props?: UseSuperFormState +): { + formProps: UseSuperFormState + setFormProps: React.Dispatch>> + ask: AskFunction +} => { + const [formProps, setFormProps] = useState>({ + request: 'insert', + ...props, + }) + + return { + formProps, + setFormProps, + ask: (request: RequestType, buffer: any) => { + setFormProps((curr) => ({ + ...curr, + request, + value: buffer, + })) + }, + } +} + +export default useSuperFormState +export type { UseSuperFormState } diff --git a/src/Form/hooks/useRemote.tsx b/src/Form/hooks/useRemote.tsx new file mode 100644 index 0000000..07603a2 --- /dev/null +++ b/src/Form/hooks/useRemote.tsx @@ -0,0 +1,123 @@ +import { useEffect, type MutableRefObject } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useFormContext, useFormState, type FieldValues } from 'react-hook-form' +import { useStore } from '../store/SuperForm.store' +import { useApiURL } from '../config/ApiConfig' +import { getNestedValue } from '../utils/getNestedValue' +import { fetchClient, type FetchResponse, FetchError } from '../utils/fetchClient' +import type { GridRef } from '../types' + +const useRemote = (gridRef?: MutableRefObject | null>) => { + // Component store State + const { onResetForm, remote, request, useMutationOptions, useQueryOptions, value } = useStore( + (state) => ({ + onResetForm: state.onResetForm, + remote: state.remote, + request: state.request, + useMutationOptions: state.useMutationOptions, + useQueryOptions: state.useQueryOptions, + value: state.value, + }) + ) + + // Component Hooks + const form = useFormContext() + const { isDirty } = useFormState({ control: form.control }) + + // Component use Effects + const qc = useQueryClient() + + // Get API URL from context or override + const contextApiURL = useApiURL() + + const id = remote?.primaryKey?.includes('.') + ? getNestedValue(remote?.primaryKey, value) + : value?.[remote?.primaryKey ?? ''] + + const queryKey = useQueryOptions?.queryKey || [remote?.tableName, id] + + const enabled = + useQueryOptions?.enabled || + !!( + (remote?.enabled ?? true) && + (remote?.tableName ?? '').length > 0 && + (request === 'change' || request === 'delete') && + String(id) !== 'undefined' && + (String(id)?.length ?? 0) > 0 + ) + + let url = remote?.apiURL ?? `${contextApiURL}/${remote?.tableName}` + url = url?.endsWith('/') ? url.substring(0, url.length - 1) : url + + const { isSuccess, status, data, isFetching } = useQuery>({ + queryKey, + queryFn: () => fetchClient.get(`${url}/${id}`, remote?.apiOptions), + enabled, + refetchOnMount: 'always', + refetchOnReconnect: !isDirty, + refetchOnWindowFocus: !isDirty, + staleTime: 0, + gcTime: 0, + ...useQueryOptions, + }) + + const changeMut = useMutation({ + // @ts-ignore + mutationFn: (mutVal: T) => { + if (!remote?.tableName || !remote?.primaryKey) { + return Promise.resolve(null) + } + + return request === 'insert' + ? fetchClient.post(url, mutVal, remote?.apiOptions) + : request === 'change' + ? fetchClient.post(`${url}/${id}`, mutVal, remote?.apiOptions) + : request === 'delete' + ? fetchClient.delete(`${url}/${id}`, remote?.apiOptions) + : Promise.resolve(null) + }, + onSettled: (response: FetchResponse | null) => { + qc?.invalidateQueries({ queryKey: [remote?.tableName] }) + + if (request !== 'delete' && response) { + if (onResetForm) { + onResetForm(response?.data, form).then(() => { + form.reset(response?.data, { keepDirty: false }) + }) + } else { + form.reset(response?.data, { keepDirty: false }) + } + } + + gridRef?.current?.refresh?.() + // @ts-ignore + gridRef?.current?.selectRow?.(response?.data?.[remote?.primaryKey ?? '']) + }, + ...useMutationOptions, + }) + + useEffect(() => { + if (isSuccess && status === 'success' && enabled && !isFetching) { + if (!Object.keys(data?.data ?? {}).includes(remote?.primaryKey ?? '')) { + throw new Error('Primary key not found in remote data') + } + + if (onResetForm) { + onResetForm(data?.data, form).then((resetData) => { + form.reset(resetData) + }) + } else { + form.reset(data?.data) + } + } + }, [isSuccess, status, enabled, isFetching]) + + return { + error: changeMut.error as FetchError, + isFetching: (enabled ? isFetching : false) || changeMut?.isPending, + mutateAsync: changeMut.mutateAsync, + queryKey, + } +} + +export default useRemote diff --git a/src/Form/store/FormLayout.store.tsx b/src/Form/store/FormLayout.store.tsx new file mode 100644 index 0000000..80c7dbe --- /dev/null +++ b/src/Form/store/FormLayout.store.tsx @@ -0,0 +1,48 @@ +import React, { createContext, useContext, type ReactNode } from 'react' +import { create } from 'zustand' +import type { RequestType } from '../types' + +interface FormLayoutState { + request: RequestType + loading: boolean + dirty: boolean + onCancel?: () => void + onSubmit?: () => void + setState: (key: string, value: any) => void +} + +const createFormLayoutStore = (initialProps: any) => + create((set) => ({ + request: initialProps.request || 'insert', + loading: initialProps.loading || false, + dirty: initialProps.dirty || false, + onCancel: initialProps.onCancel, + onSubmit: initialProps.onSubmit, + setState: (key, value) => set({ [key]: value }), + })) + +const FormLayoutStoreContext = createContext | null>(null) + +export const FormLayoutStoreProvider: React.FC<{ children: ReactNode; [key: string]: any }> = ({ + children, + ...props +}) => { + const storeRef = React.useRef>() + if (!storeRef.current) { + storeRef.current = createFormLayoutStore(props) + } + + return ( + + {children} + + ) +} + +export const useFormLayoutStore = (selector: (state: FormLayoutState) => T): T => { + const store = useContext(FormLayoutStoreContext) + if (!store) { + throw new Error('useFormLayoutStore must be used within FormLayoutStoreProvider') + } + return store(selector) +} diff --git a/src/Form/store/SuperForm.store.tsx b/src/Form/store/SuperForm.store.tsx new file mode 100644 index 0000000..22d62ce --- /dev/null +++ b/src/Form/store/SuperForm.store.tsx @@ -0,0 +1,22 @@ +import type { SuperFormProviderProps } from '../types' +import { createSyncStore } from '@warkypublic/zustandsyncstore' + +const { Provider, useStore } = createSyncStore((set) => ({ + request: 'insert', + setRequest: (request) => { + set({ request }) + }, + + value: undefined, + setValue: (value) => { + set({ value }) + }, + + noCloseOnSubmit: false, + setNoCloseOnSubmit: (noCloseOnSubmit) => { + set({ noCloseOnSubmit }) + }, +})) + +export { Provider, useStore } +export const useSuperFormStore = useStore diff --git a/src/Form/styles/Form.module.css b/src/Form/styles/Form.module.css new file mode 100644 index 0000000..aa6f336 --- /dev/null +++ b/src/Form/styles/Form.module.css @@ -0,0 +1,10 @@ +.disabled { + pointer-events: none; + opacity: 0.9; +} + +.sticky { + position: -webkit-sticky; + position: sticky; + bottom: 0; +} diff --git a/src/Form/types/form.types.ts b/src/Form/types/form.types.ts new file mode 100644 index 0000000..c415c1b --- /dev/null +++ b/src/Form/types/form.types.ts @@ -0,0 +1,135 @@ +import type { UseMutationOptions, UseMutationResult, UseQueryOptions } from '@tanstack/react-query' +import type { FieldValues, UseFormProps, UseFormReturn, UseFormStateReturn } from 'react-hook-form' +import type { ModalProps, PaperProps, PopoverProps, DrawerProps } from '@mantine/core' +import type { RemoteConfig } from './remote.types' + +export type RequestType = 'insert' | 'change' | 'view' | 'select' | 'delete' | 'get' | 'set' + +// Grid integration types (simplified - removes BTGlideRef dependency) +export interface GridRef { + refresh?: () => void + selectRow?: (id: any) => void +} + +export interface FormSectionBodyProps { + // Add properties as needed from original FormLayout + [key: string]: any +} + +export interface FormSectionFooterProps { + // Add properties as needed from original FormLayout + [key: string]: any +} + +export interface BodyRightSection { + opened?: boolean + setOpened?: (opened: boolean) => void + w: number | string + hideToggleButton?: boolean + paperProps?: PaperProps + render: (props: { + form: UseFormReturn + formValue: T + isFetching: boolean + opened: boolean + queryKey: any + setOpened: (opened: boolean) => void + }) => React.ReactNode +} + +export interface SuperFormLayoutProps { + buttonTitles?: { submit?: string; cancel?: string } + extraButtons?: React.ReactNode | ((form: UseFormReturn) => React.ReactNode) + noFooter?: boolean + noHeader?: boolean + noLayout?: boolean + bodySectionProps?: Partial + footerSectionProps?: + | Partial + | ((ref: React.RefObject>) => Partial) + rightSection?: React.ReactNode + bodyRightSection?: BodyRightSection + title?: string + showErrorList?: boolean +} + +export interface CommonFormProps { + gridRef?: React.MutableRefObject | null> + layoutProps?: SuperFormLayoutProps + meta?: { [key: string]: any } + nested?: boolean + onCancel?: (request: RequestType) => void + onLayoutMounted?: () => void + onLayoutUnMounted?: () => void + onResetForm?: (data: T, form?: UseFormReturn) => Promise + onBeforeSubmit?: ( + data: T, + request: RequestType, + form?: UseFormReturn + ) => Promise + onSubmit?: ( + data: T, + request: RequestType, + formData?: T, + form?: UseFormReturn, + closeForm?: boolean + ) => void + primeData?: any + readonly?: boolean + remote?: RemoteConfig + request: RequestType + persist?: boolean | { storageKey?: string } + useMutationOptions?: UseMutationOptions + useQueryOptions?: Partial> + value?: T | null +} + +export interface SuperFormProps extends CommonFormProps { + children: React.ReactNode | ((props: UseFormReturn) => React.ReactNode) + useFormProps?: UseFormProps +} + +export interface SuperFormProviderProps extends Omit, 'children'> { + children?: React.ReactNode +} + +export interface SuperFormModalProps extends SuperFormProps { + modalProps: ModalProps + noCloseOnSubmit?: boolean +} + +export interface SuperFormPopoverProps extends SuperFormProps { + popoverProps?: Omit + target: any + noCloseOnSubmit?: boolean +} + +export interface ExtendedDrawerProps extends DrawerProps { + // Add any extended drawer props needed + [key: string]: any +} + +export interface SuperFormDrawerProps extends SuperFormProps { + drawerProps: ExtendedDrawerProps + noCloseOnSubmit?: boolean +} + +export interface SuperFormRef { + form: UseFormReturn + mutation: Partial> + submit: (closeForm?: boolean, afterSubmit?: (data: T | any) => void) => Promise + queryKey?: any + getFormState: () => UseFormStateReturn +} + +export interface SuperFormDrawerRef extends SuperFormRef { + drawer: HTMLDivElement | null +} + +export interface SuperFormModalRef extends SuperFormRef { + modal: HTMLDivElement | null +} + +export interface SuperFormPopoverRef extends SuperFormRef { + popover: HTMLDivElement | null +} diff --git a/src/Form/types/index.ts b/src/Form/types/index.ts new file mode 100644 index 0000000..31e655c --- /dev/null +++ b/src/Form/types/index.ts @@ -0,0 +1,2 @@ +export * from './form.types' +export * from './remote.types' diff --git a/src/Form/types/remote.types.ts b/src/Form/types/remote.types.ts new file mode 100644 index 0000000..3101e33 --- /dev/null +++ b/src/Form/types/remote.types.ts @@ -0,0 +1,11 @@ +export interface RemoteConfig { + apiOptions?: RequestInit + apiURL?: string + enabled?: boolean + fetchSize?: number + hotFields?: string[] + primaryKey?: string + sqlFilter?: string + tableName: string + uniqueKeys?: string[] +} diff --git a/src/Form/utils/fetchClient.ts b/src/Form/utils/fetchClient.ts new file mode 100644 index 0000000..23e899f --- /dev/null +++ b/src/Form/utils/fetchClient.ts @@ -0,0 +1,161 @@ +export interface FetchOptions extends RequestInit { + params?: Record + timeout?: number +} + +export interface FetchResponse { + data: T + status: number + statusText: string + ok: boolean + error?: string +} + +export class FetchError extends Error { + constructor( + public message: string, + public status?: number, + public response?: any + ) { + super(message) + this.name = 'FetchError' + } +} + +/** + * Fetch wrapper with timeout support and axios-like interface + */ +async function fetchWithTimeout( + url: string, + options: FetchOptions = {} +): Promise { + const { timeout = 30000, ...fetchOptions } = options + + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), timeout) + + try { + const response = await fetch(url, { + ...fetchOptions, + signal: controller.signal, + }) + clearTimeout(timeoutId) + return response + } catch (error) { + clearTimeout(timeoutId) + throw error + } +} + +/** + * GET request + */ +export async function get( + url: string, + options?: FetchOptions +): Promise> { + try { + const response = await fetchWithTimeout(url, { + ...options, + method: 'GET', + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + }) + + const data = await response.json() + + return { + data, + status: response.status, + statusText: response.statusText, + ok: response.ok, + error: response.ok ? undefined : data?.message || data?.error || response.statusText, + } + } catch (error) { + throw new FetchError( + error instanceof Error ? error.message : 'Network request failed', + undefined, + error + ) + } +} + +/** + * POST request + */ +export async function post( + url: string, + data?: any, + options?: FetchOptions +): Promise> { + try { + const response = await fetchWithTimeout(url, { + ...options, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + body: JSON.stringify(data), + }) + + const responseData = await response.json() + + return { + data: responseData, + status: response.status, + statusText: response.statusText, + ok: response.ok, + error: response.ok ? undefined : responseData?.message || responseData?.error || response.statusText, + } + } catch (error) { + throw new FetchError( + error instanceof Error ? error.message : 'Network request failed', + undefined, + error + ) + } +} + +/** + * DELETE request + */ +export async function del( + url: string, + options?: FetchOptions +): Promise> { + try { + const response = await fetchWithTimeout(url, { + ...options, + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + }) + + const data = await response.json().catch(() => ({})) + + return { + data, + status: response.status, + statusText: response.statusText, + ok: response.ok, + error: response.ok ? undefined : data?.message || data?.error || response.statusText, + } + } catch (error) { + throw new FetchError( + error instanceof Error ? error.message : 'Network request failed', + undefined, + error + ) + } +} + +export const fetchClient = { + get, + post, + delete: del, +} diff --git a/src/Form/utils/getNestedValue.ts b/src/Form/utils/getNestedValue.ts new file mode 100644 index 0000000..be6265f --- /dev/null +++ b/src/Form/utils/getNestedValue.ts @@ -0,0 +1,9 @@ +/** + * Retrieves a nested value from an object using dot notation path + * @param path - Dot-separated path (e.g., "user.address.city") + * @param obj - Object to extract value from + * @returns The value at the specified path, or undefined if not found + */ +export const getNestedValue = (path: string, obj: any): any => { + return path.split('.').reduce((prev, curr) => prev?.[curr], obj) +} diff --git a/src/Form/utils/openConfirmModal.ts b/src/Form/utils/openConfirmModal.ts new file mode 100644 index 0000000..baef0fa --- /dev/null +++ b/src/Form/utils/openConfirmModal.ts @@ -0,0 +1,30 @@ +import React from 'react' +import { Stack, Text } from '@mantine/core' +import { modals } from '@mantine/modals' + +export const openConfirmModal = ( + onConfirm: () => void, + onCancel?: (() => void) | null, + description?: string | null +) => + modals.openConfirmModal({ + size: 'xs', + children: ( + + + You have unsaved changes in this form. + + + {description ?? + 'Closing now will discard any modifications you have made. Are you sure you want to continue?'} + + + ), + labels: { confirm: description ? 'Restore' : 'Confirm', cancel: 'Cancel' }, + confirmProps: { color: description ? 'blue' : 'red', size: 'compact-xs' }, + cancelProps: { size: 'compact-xs' }, + groupProps: { gap: 'xs' }, + withCloseButton: false, + onConfirm, + onCancel, + })