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} + > +
+
{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, + }) 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', }, }; -