From b2817f4233b623a53a6623c1c6bea95314a729ba Mon Sep 17 00:00:00 2001 From: Hein Date: Sun, 11 Jan 2026 09:45:03 +0200 Subject: [PATCH 1/3] Form prototype --- src/Form/components/Form.tsx | 38 ++ src/Form/components/FormLayout.tsx | 55 +++ src/Form/components/FormSection.tsx | 117 ++++++ src/Form/components/SuperForm.tsx | 34 ++ src/Form/components/SuperFormLayout.tsx | 364 ++++++++++++++++++ src/Form/components/SuperFormPersist.tsx | 66 ++++ src/Form/config/ApiConfig.tsx | 43 +++ .../containers/Drawer/SuperFormDrawer.tsx | 146 +++++++ src/Form/containers/Modal/SuperFormModal.tsx | 105 +++++ .../containers/Popover/SuperFormPopover.tsx | 116 ++++++ src/Form/hooks/use-drawer-form-state.tsx | 96 +++++ src/Form/hooks/use-modal-form-state.tsx | 97 +++++ src/Form/hooks/use-popover-form-state.tsx | 97 +++++ src/Form/hooks/use-subscribe.tsx | 40 ++ src/Form/hooks/use-super-form-state.tsx | 38 ++ src/Form/hooks/useRemote.tsx | 123 ++++++ src/Form/store/FormLayout.store.tsx | 48 +++ src/Form/store/SuperForm.store.tsx | 22 ++ src/Form/styles/Form.module.css | 10 + src/Form/types/form.types.ts | 135 +++++++ src/Form/types/index.ts | 2 + src/Form/types/remote.types.ts | 11 + src/Form/utils/fetchClient.ts | 161 ++++++++ src/Form/utils/getNestedValue.ts | 9 + src/Form/utils/openConfirmModal.ts | 30 ++ 25 files changed, 2003 insertions(+) create mode 100644 src/Form/components/Form.tsx create mode 100644 src/Form/components/FormLayout.tsx create mode 100644 src/Form/components/FormSection.tsx create mode 100644 src/Form/components/SuperForm.tsx create mode 100644 src/Form/components/SuperFormLayout.tsx create mode 100644 src/Form/components/SuperFormPersist.tsx create mode 100644 src/Form/config/ApiConfig.tsx create mode 100644 src/Form/containers/Drawer/SuperFormDrawer.tsx create mode 100644 src/Form/containers/Modal/SuperFormModal.tsx create mode 100644 src/Form/containers/Popover/SuperFormPopover.tsx create mode 100644 src/Form/hooks/use-drawer-form-state.tsx create mode 100644 src/Form/hooks/use-modal-form-state.tsx create mode 100644 src/Form/hooks/use-popover-form-state.tsx create mode 100644 src/Form/hooks/use-subscribe.tsx create mode 100644 src/Form/hooks/use-super-form-state.tsx create mode 100644 src/Form/hooks/useRemote.tsx create mode 100644 src/Form/store/FormLayout.store.tsx create mode 100644 src/Form/store/SuperForm.store.tsx create mode 100644 src/Form/styles/Form.module.css create mode 100644 src/Form/types/form.types.ts create mode 100644 src/Form/types/index.ts create mode 100644 src/Form/types/remote.types.ts create mode 100644 src/Form/utils/fetchClient.ts create mode 100644 src/Form/utils/getNestedValue.ts create mode 100644 src/Form/utils/openConfirmModal.ts 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, + }) -- 2.49.1 From 095ddf6162eea61bd32c10cae890b9da25138512 Mon Sep 17 00:00:00 2001 From: Hein Date: Mon, 12 Jan 2026 23:19:25 +0200 Subject: [PATCH 2/3] =?UTF-8?q?refactor(former):=20=F0=9F=94=84=20restruct?= =?UTF-8?q?ure=20form=20components=20and=20stores?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Remove unused FormLayout and SuperForm stores. * Consolidate form logic into Former component. * Implement new Former layout and types. * Update stories for new Former component. * Clean up unused styles and types across the project. --- .storybook/previewDecorator.tsx | 9 +- eslint.config.ts | 11 +- package.json | 5 + pnpm-lock.yaml | 31 ++ src/Form/components/Form.tsx | 38 -- src/Form/components/FormLayout.tsx | 55 --- src/Form/components/FormSection.tsx | 117 ------ src/Form/components/SuperForm.tsx | 34 -- src/Form/components/SuperFormLayout.tsx | 364 ------------------ src/Form/components/SuperFormPersist.tsx | 66 ---- src/Form/config/ApiConfig.tsx | 43 --- .../containers/Drawer/SuperFormDrawer.tsx | 146 ------- src/Form/containers/Modal/SuperFormModal.tsx | 105 ----- .../containers/Popover/SuperFormPopover.tsx | 116 ------ src/Form/hooks/use-drawer-form-state.tsx | 96 ----- src/Form/hooks/use-modal-form-state.tsx | 97 ----- src/Form/hooks/use-popover-form-state.tsx | 97 ----- src/Form/hooks/use-subscribe.tsx | 40 -- src/Form/hooks/use-super-form-state.tsx | 38 -- src/Form/hooks/useRemote.tsx | 123 ------ src/Form/store/FormLayout.store.tsx | 48 --- src/Form/store/SuperForm.store.tsx | 22 -- src/Form/styles/Form.module.css | 10 - src/Form/types/form.types.ts | 135 ------- src/Form/types/index.ts | 2 - src/Form/types/remote.types.ts | 11 - src/Form/utils/fetchClient.ts | 161 -------- src/Form/utils/getNestedValue.ts | 9 - src/Form/utils/openConfirmModal.ts | 30 -- src/Former/Former.store.tsx | 188 +++++++++ src/Former/Former.tsx | 115 ++++++ src/Former/Former.types.ts | 73 ++++ src/Former/FormerLayout.tsx | 93 +++++ src/Former/index.ts | 0 src/Former/stories/Gridler.goapi.stories.tsx | 42 ++ src/Former/stories/example.tsx | 118 ++++++ src/Former/todo.md | 11 + src/Gridler/components/BottomBar.tsx | 2 +- src/Gridler/stories/Gridler.goapi.stories.tsx | 10 +- .../stories/Gridler.localdata.stories.tsx | 3 +- .../MantineBetterMenu.stories.tsx | 23 +- 41 files changed, 710 insertions(+), 2027 deletions(-) delete mode 100644 src/Form/components/Form.tsx delete mode 100644 src/Form/components/FormLayout.tsx delete mode 100644 src/Form/components/FormSection.tsx delete mode 100644 src/Form/components/SuperForm.tsx delete mode 100644 src/Form/components/SuperFormLayout.tsx delete mode 100644 src/Form/components/SuperFormPersist.tsx delete mode 100644 src/Form/config/ApiConfig.tsx delete mode 100644 src/Form/containers/Drawer/SuperFormDrawer.tsx delete mode 100644 src/Form/containers/Modal/SuperFormModal.tsx delete mode 100644 src/Form/containers/Popover/SuperFormPopover.tsx delete mode 100644 src/Form/hooks/use-drawer-form-state.tsx delete mode 100644 src/Form/hooks/use-modal-form-state.tsx delete mode 100644 src/Form/hooks/use-popover-form-state.tsx delete mode 100644 src/Form/hooks/use-subscribe.tsx delete mode 100644 src/Form/hooks/use-super-form-state.tsx delete mode 100644 src/Form/hooks/useRemote.tsx delete mode 100644 src/Form/store/FormLayout.store.tsx delete mode 100644 src/Form/store/SuperForm.store.tsx delete mode 100644 src/Form/styles/Form.module.css delete mode 100644 src/Form/types/form.types.ts delete mode 100644 src/Form/types/index.ts delete mode 100644 src/Form/types/remote.types.ts delete mode 100644 src/Form/utils/fetchClient.ts delete mode 100644 src/Form/utils/getNestedValue.ts delete mode 100644 src/Form/utils/openConfirmModal.ts create mode 100644 src/Former/Former.store.tsx create mode 100644 src/Former/Former.tsx create mode 100644 src/Former/Former.types.ts create mode 100644 src/Former/FormerLayout.tsx create mode 100644 src/Former/index.ts create mode 100644 src/Former/stories/Gridler.goapi.stories.tsx create mode 100644 src/Former/stories/example.tsx create mode 100644 src/Former/todo.md diff --git a/.storybook/previewDecorator.tsx b/.storybook/previewDecorator.tsx index 7b01dd9..36e0979 100644 --- a/.storybook/previewDecorator.tsx +++ b/.storybook/previewDecorator.tsx @@ -1,13 +1,16 @@ import { MantineProvider } from '@mantine/core'; +import { ModalsProvider } from '@mantine/modals'; import '@mantine/core/styles.css'; export function PreviewDecorator(Story: any, { parameters }: any) { console.log('Rendering decorator', parameters); return ( -
- -
+ +
+ +
+
); } diff --git a/eslint.config.ts b/eslint.config.ts index f5ee91a..74afdf7 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -16,19 +16,22 @@ const config = defineConfig([ plugins: { js }, }, // reactHooks.configs['recommended-latest'], - {...reactRefresh.configs.vite, ignores: ['**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],}, + { ...reactRefresh.configs.vite, ignores: ['**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'] }, tseslint.configs.recommended, { ...pluginReact.configs.flat.recommended, - ignores: ['**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], - rules: {...pluginReact.configs.flat.recommended.rules, + ignores: ['**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}', '*stories.tsx'], + rules: { + ...pluginReact.configs.flat.recommended.rules, 'react/react-in-jsx-scope': 'off', - } + 'react-refresh/only-export-components': 'warn', + }, }, perfectionist.configs['recommended-alphabetical'], { rules: { '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/ban-ts-comment': 'off', }, }, ]); diff --git a/package.json b/package.json index 48521e8..5599ff1 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,9 @@ "./oranguru.css": "./src/oranguru.css" }, "dependencies": { + "moment": "^2.30.1" + }, "devDependencies": { "@changesets/cli": "^2.29.7", @@ -94,10 +96,13 @@ "@mantine/core": "^8.3.1", "@mantine/hooks": "^8.3.1", "@mantine/notifications": "^8.3.5", + "@mantine/modals": "^8.3.5", "@tabler/icons-react": "^3.35.0", "@tanstack/react-query": "^5.90.5", "@warkypublic/artemis-kit": "^1.0.10", "@warkypublic/zustandsyncstore": "^0.0.4", + "react-hook-form": "^7.71.0", + "immer": "^10.1.3", "react": ">= 19.0.0", "react-dom": ">= 19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35a63f7..962ddeb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@mantine/hooks': specifier: ^8.3.1 version: 8.3.1(react@19.2.0) + '@mantine/modals': + specifier: ^8.3.5 + version: 8.3.12(@mantine/core@8.3.1(@mantine/hooks@8.3.1(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@mantine/hooks@8.3.1(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@mantine/notifications': specifier: ^8.3.5 version: 8.3.5(@mantine/core@8.3.1(@mantine/hooks@8.3.1(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@mantine/hooks@8.3.1(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -38,6 +41,9 @@ importers: moment: specifier: ^2.30.1 version: 2.30.1 + react-hook-form: + specifier: ^7.71.0 + version: 7.71.0(react@19.2.0) use-sync-external-store: specifier: '>= 1.4.0' version: 1.5.0(react@19.2.0) @@ -702,6 +708,14 @@ packages: peerDependencies: react: ^18.x || ^19.x + '@mantine/modals@8.3.12': + resolution: {integrity: sha512-+uRyGe2lLy601qlMk+8aR9d/Aibu+dZi6Jcmvm5z8Gw4ocviyMMlnd8BLSQ/Jvib2OX8fWj+yUQN7FMQ4Rbwjw==} + peerDependencies: + '@mantine/core': 8.3.12 + '@mantine/hooks': 8.3.12 + react: ^18.x || ^19.x + react-dom: ^18.x || ^19.x + '@mantine/notifications@8.3.5': resolution: {integrity: sha512-8TvzrPxfdtOLGTalv7Ei1hy2F6KbR3P7/V73yw3AOKhrf1ydS89sqV2ShbsucHGJk9Pto0wjdTPd8Q7pm5MAYw==} peerDependencies: @@ -2843,6 +2857,12 @@ packages: resolution: {integrity: sha512-OeR2jAxdoqUMHIn/nS9fgreI5hSpgGoL5ezdal4+oO7YSSgJR8ga+PkYGJrSrJ9MKlPcQjMQXnketrD7WNmNsg==} engines: {node: '>= 6'} + react-hook-form@7.71.0: + resolution: {integrity: sha512-oFDt/iIFMV9ZfV52waONXzg4xuSlbwKUPvXVH2jumL1me5qFhBMc4knZxuXiZ2+j6h546sYe3ZKJcg/900/iHw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-html-attributes@1.4.6: resolution: {integrity: sha512-uS3MmThNKFH2EZUQQw4k5pIcU7XIr208UE5dktrj/GOH1CMagqxDl4DCLpt3o2l9x+IB5nVYBeN3Cr4IutBXAg==} @@ -4285,6 +4305,13 @@ snapshots: dependencies: react: 19.2.0 + '@mantine/modals@8.3.12(@mantine/core@8.3.1(@mantine/hooks@8.3.1(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@mantine/hooks@8.3.1(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@mantine/core': 8.3.1(@mantine/hooks@8.3.1(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@mantine/hooks': 8.3.1(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + '@mantine/notifications@8.3.5(@mantine/core@8.3.1(@mantine/hooks@8.3.1(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@mantine/hooks@8.3.1(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@mantine/core': 8.3.1(@mantine/hooks@8.3.1(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -6686,6 +6713,10 @@ snapshots: dependencies: prop-types: 15.8.1 + react-hook-form@7.71.0(react@19.2.0): + dependencies: + react: 19.2.0 + react-html-attributes@1.4.6: dependencies: html-element-attributes: 1.3.1 diff --git a/src/Form/components/Form.tsx b/src/Form/components/Form.tsx deleted file mode 100644 index 4c7c047..0000000 --- a/src/Form/components/Form.tsx +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 3fe1c74..0000000 --- a/src/Form/components/FormLayout.tsx +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 7fa9368..0000000 --- a/src/Form/components/FormSection.tsx +++ /dev/null @@ -1,117 +0,0 @@ -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 deleted file mode 100644 index 935a8f7..0000000 --- a/src/Form/components/SuperForm.tsx +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 51dc6a2..0000000 --- a/src/Form/components/SuperFormLayout.tsx +++ /dev/null @@ -1,364 +0,0 @@ -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 deleted file mode 100644 index 8026511..0000000 --- a/src/Form/components/SuperFormPersist.tsx +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index 588898b..0000000 --- a/src/Form/config/ApiConfig.tsx +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 111ad82..0000000 --- a/src/Form/containers/Drawer/SuperFormDrawer.tsx +++ /dev/null @@ -1,146 +0,0 @@ -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 deleted file mode 100644 index 46bd768..0000000 --- a/src/Form/containers/Modal/SuperFormModal.tsx +++ /dev/null @@ -1,105 +0,0 @@ -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 deleted file mode 100644 index 264af05..0000000 --- a/src/Form/containers/Popover/SuperFormPopover.tsx +++ /dev/null @@ -1,116 +0,0 @@ -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 deleted file mode 100644 index 57dfbac..0000000 --- a/src/Form/hooks/use-drawer-form-state.tsx +++ /dev/null @@ -1,96 +0,0 @@ -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 deleted file mode 100644 index 9156aa8..0000000 --- a/src/Form/hooks/use-modal-form-state.tsx +++ /dev/null @@ -1,97 +0,0 @@ -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 deleted file mode 100644 index 6b86607..0000000 --- a/src/Form/hooks/use-popover-form-state.tsx +++ /dev/null @@ -1,97 +0,0 @@ -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 deleted file mode 100644 index 12dec90..0000000 --- a/src/Form/hooks/use-subscribe.tsx +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 90b6e25..0000000 --- a/src/Form/hooks/use-super-form-state.tsx +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index 07603a2..0000000 --- a/src/Form/hooks/useRemote.tsx +++ /dev/null @@ -1,123 +0,0 @@ -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 deleted file mode 100644 index 80c7dbe..0000000 --- a/src/Form/store/FormLayout.store.tsx +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index 22d62ce..0000000 --- a/src/Form/store/SuperForm.store.tsx +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index aa6f336..0000000 --- a/src/Form/styles/Form.module.css +++ /dev/null @@ -1,10 +0,0 @@ -.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 deleted file mode 100644 index c415c1b..0000000 --- a/src/Form/types/form.types.ts +++ /dev/null @@ -1,135 +0,0 @@ -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 deleted file mode 100644 index 31e655c..0000000 --- a/src/Form/types/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './form.types' -export * from './remote.types' diff --git a/src/Form/types/remote.types.ts b/src/Form/types/remote.types.ts deleted file mode 100644 index 3101e33..0000000 --- a/src/Form/types/remote.types.ts +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 23e899f..0000000 --- a/src/Form/utils/fetchClient.ts +++ /dev/null @@ -1,161 +0,0 @@ -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 deleted file mode 100644 index be6265f..0000000 --- a/src/Form/utils/getNestedValue.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * 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 deleted file mode 100644 index baef0fa..0000000 --- a/src/Form/utils/openConfirmModal.ts +++ /dev/null @@ -1,30 +0,0 @@ -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, - }) diff --git a/src/Former/Former.store.tsx b/src/Former/Former.store.tsx new file mode 100644 index 0000000..3806971 --- /dev/null +++ b/src/Former/Former.store.tsx @@ -0,0 +1,188 @@ +import { createSyncStore } from '@warkypublic/zustandsyncstore'; +import { produce } from 'immer'; + +import type { FormerProps, FormerState } from './Former.types'; + +const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore< + FormerState & Partial>, + FormerProps +>( + (set, get) => ({ + getState: (key) => { + const current = get(); + return current?.[key]; + }, + load: async (reset?: boolean) => { + try { + set({ loading: true }); + const keyName = get()?.apiKeyField || 'id'; + const keyValue = (get().values as any)?.[keyName] ?? (get().primeData as any)?.[keyName]; + if (get().onAPICall && keyValue !== undefined) { + let data = await get().onAPICall!( + 'read', + get().request || 'insert', + get().values, + keyValue + ); + if (get().afterGet) { + data = await get().afterGet!({ ...data }); + } + set({ loading: false, values: data }); + get().onChange?.(data); + } + if (reset && get().getFormMethods) { + const formMethods = get().getFormMethods!(); + formMethods.reset(); + } + } catch (e) { + set({ error: (e as Error)?.message ?? e, loading: false }); + } + set({ loading: false }); + }, + + onChange: (values) => { + set({ values }); + }, + request: 'insert', + reset: async () => { + const state = get(); + if (state.getFormMethods) { + if (state.request !== 'insert') { + await state.load(true); + } + + const formMethods = state.getFormMethods!(); + formMethods.reset({ ...state.values, ...state.primeData }); + } + }, + save: async (e?: React.BaseSyntheticEvent | undefined) => { + try { + const keepOpen = get().keepOpen ?? false; + set({ loading: true }); + if (get().getFormMethods) { + const formMethods = get().getFormMethods!(); + + let data = formMethods.getValues(); + + if (get().beforeSave) { + const newData = await get().beforeSave!(data); + data = newData; + } + + let exit = false; + const handler = formMethods.handleSubmit( + (newdata) => { + data = newdata; + }, + (errors) => { + set({ error: errors.root?.message || 'Validation errors', loading: false }); + exit = true; + } + ); + + await handler(e); + + //console.log('Former.store.tsx save called', success, e, data, get().getFormMethods); + if (exit) { + set({ loading: false }); + return undefined; + } + + if (get().request === 'delete' && !get().deleteConfirmed) { + const confirmed = (await get().onConfirmDelete?.(data)) ?? false; + if (!confirmed) { + set({ loading: false }); + return undefined; + } + } + + if (get().onAPICall) { + const keyName = get()?.apiKeyField || 'id'; + const keyValue = + (get().values as any)?.[keyName] ?? (get().primeData as any)?.[keyName]; + const savedData = await get().onAPICall!( + 'mutate', + get().request || 'insert', + data, + keyValue + ); + if (get().afterSave) { + await get().afterSave!(savedData); + } + set({ loading: false, values: savedData }); + get().onChange?.(savedData); + if (!keepOpen) { + get().onClose?.(savedData); + } + return savedData; + } + + set({ loading: false, values: data }); + get().onChange?.(data); + if (!keepOpen) { + get().onClose?.(data); + } + + return data; + } + } catch (e) { + set({ error: (e as Error)?.message ?? e, loading: false }); + } + + return undefined; + }, + setRequest: (request) => { + set({ request }); + }, + setState: (key, value) => { + set( + produce((state) => { + state[key] = value; + }) + ); + }, + setStateFN: (key, value) => { + const p = new Promise((resolve, reject) => { + set( + produce((state) => { + if (typeof value === 'function') { + state[key] = (value as (value: unknown) => unknown)(state[key]); + } else { + reject(new Error(`Not a function ${value}`)); + throw Error(`Not a function ${value}`); + } + }) + ); + resolve(); + }); + + return p; + }, + validate: async () => { + if (get().getFormMethods) { + const formMethods = get().getFormMethods!(); + const isValid = await formMethods.trigger(); + return isValid; + } + return true; + }, + values: undefined, + }), + ({ onConfirmDelete, primeData, request, values }) => { + let _onConfirmDelete = onConfirmDelete; + if (!onConfirmDelete) { + _onConfirmDelete = async () => { + return confirm('Are you sure you want to delete this item?'); + }; + } + return { + onConfirmDelete: _onConfirmDelete, + primeData, + request: request || 'insert', + values: { ...primeData, ...values }, + }; + } +); + +export { FormerProvider }; +export { useFormerStore }; diff --git a/src/Former/Former.tsx b/src/Former/Former.tsx new file mode 100644 index 0000000..43017ee --- /dev/null +++ b/src/Former/Former.tsx @@ -0,0 +1,115 @@ +import { forwardRef, type PropsWithChildren, useEffect, useImperativeHandle } from 'react'; +import { type FieldValues, FormProvider, useForm } from 'react-hook-form'; + +import type { FormerProps, FormerRef } from './Former.types'; + +import { FormerProvider, useFormerStore } from './Former.store'; +import { FormerLayout } from './FormerLayout'; + +const FormerInner = forwardRef, Partial> & PropsWithChildren>( + function FormerInner( + props: Partial> & PropsWithChildren, + ref: any + ) { + const { + getState, + onChange, + onClose, + onOpen, + opened, + primeData, + reset, + save, + setState, + useFormProps, + validate, + values, + wrapper, + } = useFormerStore((state) => ({ + getState: state.getState, + onChange: state.onChange, + onClose: state.onClose, + onOpen: state.onOpen, + opened: state.opened, + primeData: state.primeData, + reset: state.reset, + save: state.save, + setState: state.setState, + useFormProps: state.useFormProps, + validate: state.validate, + values: state.values, + wrapper: state.wrapper, + })); + + const formMethods = useForm({ + defaultValues: primeData, + mode: 'all', + shouldUseNativeValidation: true, + values: values, + ...useFormProps, + }); + + useImperativeHandle( + ref, + () => ({ + close: async () => { + //console.log('close called'); + onClose?.(); + setState('opened', false); + }, + getValue: () => { + return getState('values'); + }, + reset: () => { + reset(); + }, + save: async () => { + return await save(); + }, + setValue: (value: T) => { + onChange?.(value); + }, + show: async () => { + //console.log('show called'); + setState('opened', true); + onOpen?.(); + }, + validate: async () => { + return await validate(); + }, + }), + [getState, onChange] + ); + + useEffect(() => { + setState('getFormMethods', () => formMethods); + }, [formMethods]); + + return ( + + {typeof wrapper === 'function' ? ( + wrapper({props.children}, opened, onClose, onOpen, getState) + ) : ( + {props.children || null} + )} + + ); + } +); + +export const Former = forwardRef, FormerProps & PropsWithChildren>( + function Former( + props: FormerProps & PropsWithChildren, + ref: any + ) { + //if opened is false and wrapper is defined as function, do not render anything + if (!props.opened && typeof props.wrapper === 'function') { + return null; + } + return ( + + {props.children} + + ); + } +); diff --git a/src/Former/Former.types.ts b/src/Former/Former.types.ts new file mode 100644 index 0000000..e36f874 --- /dev/null +++ b/src/Former/Former.types.ts @@ -0,0 +1,73 @@ +import type { LoadingOverlayProps, ScrollAreaAutosizeProps } from '@mantine/core'; +import type { FieldValues, UseFormProps, UseFormReturn } from 'react-hook-form'; + +export interface FormerProps { + afterGet?: (data: T) => Promise | void; + afterSave?: (data: T) => Promise | void; + apiKeyField?: string; + beforeSave?: (data: T) => Promise | T; + disableHTMlForm?: boolean; + keepOpen?: boolean; + onAPICall?: ( + mode: 'mutate' | 'read', + request: RequestType, + value?: T, + key?: number | string + ) => Promise; + onCancel?: () => void; + onChange?: (value: T) => void; + onClose?: (data?: T) => void; + onConfirmDelete?: (values?: T) => Promise; + onOpen?: (data?: T) => void; + + opened?: boolean; + primeData?: T; + request: RequestType; + useFormProps?: UseFormProps; + values?: T; + wrapper?: ( + children: React.ReactNode, + opened: boolean | undefined, + onClose: ((data?: T) => void) | undefined, + onOpen: ((data?: T) => void) | undefined, + getState: >(key: K) => FormStateAndProps[K] + ) => React.ReactNode; +} + +export interface FormerRef { + close: () => Promise; + getValue: () => T | undefined; + reset: () => void; + save: () => Promise; + setValue: (value: T) => void; + show: () => Promise; + validate: () => Promise; +} + +export interface FormerState { + deleteConfirmed?: boolean; + error?: string; + getFormMethods?: () => UseFormReturn; + getState: >(key: K) => FormStateAndProps[K]; + load: (reset?: boolean) => Promise; + loading?: boolean; + loadingOverlayProps?: LoadingOverlayProps; + reset: (e?: React.BaseSyntheticEvent | undefined) => Promise; + save: (e?: React.BaseSyntheticEvent | undefined) => Promise; + scrollAreaProps?: ScrollAreaAutosizeProps; + setRequest: (request: RequestType) => void; + setState: >( + key: K, + value: Partial>[K] + ) => void; + setStateFN: >( + key: K, + value: (current: FormStateAndProps[K]) => Partial[K]> + ) => Promise; + validate: () => Promise; +} + +export type FormStateAndProps = FormerProps & + Partial>; + +export type RequestType = 'delete' | 'insert' | 'select' | 'update' | 'view'; diff --git a/src/Former/FormerLayout.tsx b/src/Former/FormerLayout.tsx new file mode 100644 index 0000000..6b76f46 --- /dev/null +++ b/src/Former/FormerLayout.tsx @@ -0,0 +1,93 @@ +import { LoadingOverlay, ScrollAreaAutosize } from '@mantine/core'; +import { type PropsWithChildren, useEffect } from 'react'; + +import { useFormerStore } from './Former.store'; + +export const FormerLayout = (props: PropsWithChildren) => { + const { + disableHTMlForm, + getFormMethods, + load, + loading, + loadingOverlayProps, + request, + reset, + save, + scrollAreaProps, + } = useFormerStore((state) => ({ + disableHTMlForm: state.disableHTMlForm, + getFormMethods: state.getFormMethods, + load: state.load, + loading: state.loading, + loadingOverlayProps: state.loadingOverlayProps, + request: state.request, + reset: state.reset, + save: state.save, + scrollAreaProps: state.scrollAreaProps, + })); + + useEffect(() => { + if (getFormMethods) { + const formMethods = getFormMethods(); + if (formMethods && request !== 'insert') { + load(true); + } + } + }, [getFormMethods, request]); + + if (disableHTMlForm) { + return ( + + {props.children} + + + ); + } + + return ( + +
reset(e)} onSubmit={(e) => save(e)}> + {props.children} + + +
+ ); +}; diff --git a/src/Former/index.ts b/src/Former/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/Former/stories/Gridler.goapi.stories.tsx b/src/Former/stories/Gridler.goapi.stories.tsx new file mode 100644 index 0000000..15ce215 --- /dev/null +++ b/src/Former/stories/Gridler.goapi.stories.tsx @@ -0,0 +1,42 @@ +//@ts-nocheck +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { Box } from '@mantine/core'; +import { fn } from 'storybook/test'; + +import { FormTest } from './example'; + +const Renderable = (props: any) => { + return ( + + + + ); +}; + +const meta = { + // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args + args: { onClick: fn() }, + // More on argTypes: https://storybook.js.org/docs/api/argtypes + argTypes: { + backgroundColor: { control: 'color' }, + }, + component: Renderable, + parameters: { + // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout + //layout: 'centered', + }, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs + tags: ['autodocs'], + title: 'Former/Former Basic', +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args +export const BasicExample: Story = { + args: { + label: 'Test', + }, +}; diff --git a/src/Former/stories/example.tsx b/src/Former/stories/example.tsx new file mode 100644 index 0000000..2cf9316 --- /dev/null +++ b/src/Former/stories/example.tsx @@ -0,0 +1,118 @@ +import { Button, Drawer, Group, Paper, Select, Stack, Switch } from '@mantine/core'; +import { useRef, useState } from 'react'; +import { Controller } from 'react-hook-form'; + +import type { FormerRef } from '../Former.types'; + +import { Former } from '../Former'; + +export const FormTest = () => { + const [request, setRequest] = useState('insert'); + const [wrapped, setWrapped] = useState(false); + const [open, setOpen] = useState(false); + const [formData, setFormData] = useState({ a: 99 }); + console.log('formData', formData); + + const ref = useRef(null); + return ( + + } + /> + } + rules={{ required: 'Field is required' }} + /> + + + + + + + + + ); +}; diff --git a/src/Former/todo.md b/src/Former/todo.md new file mode 100644 index 0000000..890935f --- /dev/null +++ b/src/Former/todo.md @@ -0,0 +1,11 @@ +- [ ] Headerspec API +- [ ] Relspec API +- [ ] SocketSpec API +- [ ] Layout Tool + - [ ] Header Section + - [ ] Button Section + - [ ] Footer Section +- [ ] Different Loaded for saving vs loading +- [ ] Better Confirm Dialog +- [ ] Reset Confirm Dialog +- [ ] Request insert and save but keep open (must clear key from API, also add callback) diff --git a/src/Gridler/components/BottomBar.tsx b/src/Gridler/components/BottomBar.tsx index 6b294d6..00d4a71 100644 --- a/src/Gridler/components/BottomBar.tsx +++ b/src/Gridler/components/BottomBar.tsx @@ -1,4 +1,4 @@ -/* eslint-disable react/react-in-jsx-scope */ + import { useGridlerStore } from './GridlerStore'; export function BottomBar() { diff --git a/src/Gridler/stories/Gridler.goapi.stories.tsx b/src/Gridler/stories/Gridler.goapi.stories.tsx index ef2d243..046e9aa 100644 --- a/src/Gridler/stories/Gridler.goapi.stories.tsx +++ b/src/Gridler/stories/Gridler.goapi.stories.tsx @@ -1,3 +1,4 @@ +//@ts-nocheck import type { Meta, StoryObj } from '@storybook/react-vite'; import { Box } from '@mantine/core'; @@ -6,7 +7,12 @@ import { fn } from 'storybook/test'; import { GridlerGoAPIExampleEventlog } from './Examples.goapi'; const Renderable = (props: any) => { - return ; + return ( + + {' '} + + + ); }; const meta = { @@ -19,7 +25,7 @@ const meta = { component: Renderable, parameters: { // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout - layout: 'centered', + //layout: 'centered', }, // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs tags: ['autodocs'], diff --git a/src/Gridler/stories/Gridler.localdata.stories.tsx b/src/Gridler/stories/Gridler.localdata.stories.tsx index 40b46f9..0d70ade 100644 --- a/src/Gridler/stories/Gridler.localdata.stories.tsx +++ b/src/Gridler/stories/Gridler.localdata.stories.tsx @@ -1,3 +1,4 @@ +//@ts-nocheck import type { Meta, StoryObj } from '@storybook/react-vite'; import { Box } from '@mantine/core'; @@ -24,7 +25,7 @@ const meta = { component: Renderable, parameters: { // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout - layout: 'centered', + // layout: 'centered', }, // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs tags: ['autodocs'], diff --git a/src/MantineBetterMenu/MantineBetterMenu.stories.tsx b/src/MantineBetterMenu/MantineBetterMenu.stories.tsx index 1173e16..643e1ff 100644 --- a/src/MantineBetterMenu/MantineBetterMenu.stories.tsx +++ b/src/MantineBetterMenu/MantineBetterMenu.stories.tsx @@ -5,21 +5,20 @@ import { fn } from 'storybook/test'; import { MantineBetterMenusProvider, useMantineBetterMenus } from './'; - -const Renderable = (props: Record) => { +const Renderable = (props: Record) => { return ( - - - + + + ); -} +}; const Menu = () => { const menus = useMantineBetterMenus(); - //menus.setState("menus",[{id:"test",items:[{id:"1",label:"Test",onClick:()=>{console.log("Clicked")}}]}]) - - return ; -} + //menus.setState("menus",[{id:"test",items:[{id:"1",label:"Test",onClick:()=>{console.log("Clicked")}}]}]) + + return ; +}; const meta = { // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args @@ -31,7 +30,7 @@ const meta = { component: Renderable, parameters: { // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout - layout: 'centered', + //layout: 'centered', }, // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs tags: ['autodocs'], @@ -44,8 +43,6 @@ type Story = StoryObj; // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args export const BasicExample: Story = { args: { - label: 'Test', }, }; - -- 2.49.1 From 9bac48d5dd997729af8ab8861aff9b76524ea570 Mon Sep 17 00:00:00 2001 From: Hein Date: Sun, 11 Jan 2026 09:45:03 +0200 Subject: [PATCH 3/3] Form prototype --- src/Form/components/Form.tsx | 38 ++ src/Form/components/FormLayout.tsx | 55 +++ src/Form/components/FormSection.tsx | 117 ++++++ src/Form/components/SuperForm.tsx | 34 ++ src/Form/components/SuperFormLayout.tsx | 364 ++++++++++++++++++ src/Form/components/SuperFormPersist.tsx | 66 ++++ src/Form/config/ApiConfig.tsx | 43 +++ .../containers/Drawer/SuperFormDrawer.tsx | 146 +++++++ src/Form/containers/Modal/SuperFormModal.tsx | 105 +++++ .../containers/Popover/SuperFormPopover.tsx | 116 ++++++ src/Form/hooks/use-drawer-form-state.tsx | 96 +++++ src/Form/hooks/use-modal-form-state.tsx | 97 +++++ src/Form/hooks/use-popover-form-state.tsx | 97 +++++ src/Form/hooks/use-subscribe.tsx | 40 ++ src/Form/hooks/use-super-form-state.tsx | 38 ++ src/Form/hooks/useRemote.tsx | 123 ++++++ src/Form/store/FormLayout.store.tsx | 48 +++ src/Form/store/SuperForm.store.tsx | 22 ++ src/Form/styles/Form.module.css | 10 + src/Form/types/form.types.ts | 135 +++++++ src/Form/types/index.ts | 2 + src/Form/types/remote.types.ts | 11 + src/Form/utils/fetchClient.ts | 161 ++++++++ src/Form/utils/getNestedValue.ts | 9 + src/Form/utils/openConfirmModal.ts | 30 ++ 25 files changed, 2003 insertions(+) create mode 100644 src/Form/components/Form.tsx create mode 100644 src/Form/components/FormLayout.tsx create mode 100644 src/Form/components/FormSection.tsx create mode 100644 src/Form/components/SuperForm.tsx create mode 100644 src/Form/components/SuperFormLayout.tsx create mode 100644 src/Form/components/SuperFormPersist.tsx create mode 100644 src/Form/config/ApiConfig.tsx create mode 100644 src/Form/containers/Drawer/SuperFormDrawer.tsx create mode 100644 src/Form/containers/Modal/SuperFormModal.tsx create mode 100644 src/Form/containers/Popover/SuperFormPopover.tsx create mode 100644 src/Form/hooks/use-drawer-form-state.tsx create mode 100644 src/Form/hooks/use-modal-form-state.tsx create mode 100644 src/Form/hooks/use-popover-form-state.tsx create mode 100644 src/Form/hooks/use-subscribe.tsx create mode 100644 src/Form/hooks/use-super-form-state.tsx create mode 100644 src/Form/hooks/useRemote.tsx create mode 100644 src/Form/store/FormLayout.store.tsx create mode 100644 src/Form/store/SuperForm.store.tsx create mode 100644 src/Form/styles/Form.module.css create mode 100644 src/Form/types/form.types.ts create mode 100644 src/Form/types/index.ts create mode 100644 src/Form/types/remote.types.ts create mode 100644 src/Form/utils/fetchClient.ts create mode 100644 src/Form/utils/getNestedValue.ts create mode 100644 src/Form/utils/openConfirmModal.ts 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, + }) -- 2.49.1