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
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}
+ >
+
+
+
+
+ ) : (
+
+ )
+}
+
+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 (
+
+ )
+}
+
+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,
+ })
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