From cd2f6db880138502a2ca2408ab7b557d03d2a401 Mon Sep 17 00:00:00 2001 From: Hein Date: Wed, 14 Jan 2026 21:51:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(form):=20=E2=9C=A8=20enhance=20form=20func?= =?UTF-8?q?tionality=20and=20API=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactor key handling to use uniqueKeyField * Add reset functionality to clear dirty state after save * Introduce new API call specifications for REST and resolve * Implement predefined wrappers for dialogs and popovers * Update todo list to reflect completed tasks --- src/Former/Former.store.tsx | 29 ++++++- src/Former/Former.tsx | 11 ++- src/Former/Former.types.ts | 20 +++-- src/Former/FormerButtonArea.tsx | 64 ++++++++++++--- src/Former/FormerLayout.tsx | 9 +-- src/Former/FormerResolveSpecAPI.ts | 72 +++++++++++++++++ src/Former/FormerRestHeadSpecAPI.ts | 50 ++++++++++++ src/Former/FormerWrappers.tsx | 114 ++++++++++++++++++++++++++ src/Former/stories/apiFormData.tsx | 40 +++++++++ src/Former/stories/example.tsx | 121 ++++++++++++++++++---------- src/Former/todo.md | 6 +- 11 files changed, 462 insertions(+), 74 deletions(-) create mode 100644 src/Former/FormerResolveSpecAPI.ts create mode 100644 src/Former/FormerRestHeadSpecAPI.ts create mode 100644 src/Former/FormerWrappers.tsx create mode 100644 src/Former/stories/apiFormData.tsx diff --git a/src/Former/Former.store.tsx b/src/Former/Former.store.tsx index d57a57b..64597f3 100644 --- a/src/Former/Former.store.tsx +++ b/src/Former/Former.store.tsx @@ -16,7 +16,7 @@ const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore< load: async (reset?: boolean) => { try { set({ loading: true }); - const keyName = get()?.apiKeyField || 'id'; + const keyName = get()?.uniqueKeyField || 'id'; const keyValue = (get().values as any)?.[keyName] ?? (get().primeData as any)?.[keyName]; if (get().onAPICall && keyValue !== undefined) { let data = await get().onAPICall!( @@ -98,7 +98,7 @@ const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore< } if (get().onAPICall) { - const keyName = get()?.apiKeyField || 'id'; + const keyName = get()?.uniqueKeyField || 'id'; const keyValue = (get().values as any)?.[keyName] ?? (get().primeData as any)?.[keyName]; const savedData = await get().onAPICall!( @@ -112,6 +112,7 @@ const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore< } set({ loading: false, values: savedData }); get().onChange?.(savedData); + formMethods.reset(savedData); //reset with saved data to clear dirty state if (!keepOpen) { get().onClose?.(savedData); } @@ -119,6 +120,7 @@ const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore< } set({ loading: false, values: data }); + formMethods.reset(data); //reset with saved data to clear dirty state get().onChange?.(data); if (!keepOpen) { get().onClose?.(data); @@ -169,7 +171,7 @@ const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore< }, values: undefined, }), - ({ onConfirmDelete, primeData, request, values, id }) => { + ({ onConfirmDelete, primeData, request, values, id, onClose, useStoreApi }) => { let _onConfirmDelete = onConfirmDelete; if (!onConfirmDelete) { _onConfirmDelete = async () => { @@ -180,9 +182,28 @@ const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore< return { onConfirmDelete: _onConfirmDelete, primeData, - request: request || 'insert', + request: (request || 'insert').replace('change', 'update'), values: { ...primeData, ...values }, id: !id ? newUUID() : id, + onClose: () => { + const dirty = useStoreApi.getState().dirty; + const setState = useStoreApi.getState().setState; + if (dirty) { + if (confirm('You have unsaved changes. Are you sure you want to close?')) { + if (onClose) { + onClose(); + } else { + setState('opened', false); + } + } + } else { + if (onClose) { + onClose(); + } else { + setState('opened', false); + } + } + }, }; } ); diff --git a/src/Former/Former.tsx b/src/Former/Former.tsx index 43017ee..9f14ebd 100644 --- a/src/Former/Former.tsx +++ b/src/Former/Former.tsx @@ -78,11 +78,20 @@ const FormerInner = forwardRef, Partial> & Props return await validate(); }, }), - [getState, onChange] + [getState, onChange, validate, save, reset, setState, onClose, onOpen] ); useEffect(() => { setState('getFormMethods', () => formMethods); + + if (formMethods) { + formMethods.subscribe({ + formState: { isDirty: true }, + callback: ({ isDirty }) => { + setState('dirty', isDirty); + }, + }); + } }, [formMethods]); return ( diff --git a/src/Former/Former.types.ts b/src/Former/Former.types.ts index ae6f37f..ca7dee2 100644 --- a/src/Former/Former.types.ts +++ b/src/Former/Former.types.ts @@ -4,6 +4,7 @@ import type { LoadingOverlayProps, ScrollAreaAutosizeProps, } from '@mantine/core'; +import type React from 'react'; import type { FieldValues, UseFormProps, UseFormReturn } from 'react-hook-form'; export type FormerSectionRender = ( @@ -14,20 +15,22 @@ export type FormerSectionRender = ( getState: FormerState['getState'] ) => React.ReactNode; +export type FormerAPICallType = ( + mode: 'mutate' | 'read', + request: RequestType, + value?: T, + key?: number | string +) => Promise; + export interface FormerProps { id?: string; afterGet?: (data: T) => Promise | void; afterSave?: (data: T) => Promise | void; - apiKeyField?: string; + uniqueKeyField?: string; beforeSave?: (data: T) => Promise | T; disableHTMlForm?: boolean; keepOpen?: boolean; - onAPICall?: ( - mode: 'mutate' | 'read', - request: RequestType, - value?: T, - key?: number | string - ) => Promise; + onAPICall?: FormerAPICallType; onCancel?: () => void; onChange?: (value: T) => void; onClose?: (data?: T) => void; @@ -35,6 +38,7 @@ export interface FormerProps { onOpen?: (data?: T) => void; opened?: boolean; + dirty?: boolean; primeData?: T; request: RequestType; useFormProps?: UseFormProps; @@ -44,6 +48,8 @@ export interface FormerProps { layout?: { renderTop?: FormerSectionRender; renderBottom?: FormerSectionRender; + saveButtonTitle?: React.ReactNode; + closeButtonTitle?: React.ReactNode; saveButtonProps?: ButtonProps; closeButtonProps?: ButtonProps; buttonOnTop?: boolean; diff --git a/src/Former/FormerButtonArea.tsx b/src/Former/FormerButtonArea.tsx index f91d8d4..93045f0 100644 --- a/src/Former/FormerButtonArea.tsx +++ b/src/Former/FormerButtonArea.tsx @@ -1,14 +1,33 @@ -import { Group, Button } from '@mantine/core'; +import { Group, Button, Tooltip } from '@mantine/core'; import { IconX, IconDeviceFloppy } from '@tabler/icons-react'; import { useFormerStore } from './Former.store'; export const FormerButtonArea = () => { - const { save, onClose, buttonAreaGroupProps } = useFormerStore((state) => ({ + const { + save, + onClose, + buttonAreaGroupProps, + saveButtonProps, + closeButtonProps, + closeButtonTitle, + saveButtonTitle, + request, + dirty, + } = useFormerStore((state) => ({ save: state.save, onClose: state.onClose, buttonAreaGroupProps: state.layout?.buttonAreaGroupProps, + saveButtonProps: state.layout?.saveButtonProps, + closeButtonProps: state.layout?.closeButtonProps, + closeButtonTitle: state.layout?.closeButtonTitle, + saveButtonTitle: state.layout?.saveButtonTitle, + request: state.request, + dirty: state.dirty, })); + const disabledSave = + ['select', 'view'].includes(request || '') || (['update'].includes(request || '') && !dirty); + return ( { leftSection={} size="sm" px="md" - onClick={() => onClose()} miw={'8rem'} + {...closeButtonProps} + onClick={() => { + onClose(); + }} > - Close + {closeButtonTitle || 'Close'} )} - + + ); diff --git a/src/Former/FormerLayout.tsx b/src/Former/FormerLayout.tsx index 4452b93..ec76c97 100644 --- a/src/Former/FormerLayout.tsx +++ b/src/Former/FormerLayout.tsx @@ -17,8 +17,7 @@ export const FormerLayout = (props: PropsWithChildren) => { save, scrollAreaProps, id, - layout, - getState, + opened, } = useFormerStore((state) => ({ disableHTMlForm: state.disableHTMlForm, getFormMethods: state.getFormMethods, @@ -30,8 +29,8 @@ export const FormerLayout = (props: PropsWithChildren) => { save: state.save, scrollAreaProps: state.scrollAreaProps, id: state.id, - layout: state.layout, - getState: state.getState, + + opened: state.opened, })); useEffect(() => { @@ -41,7 +40,7 @@ export const FormerLayout = (props: PropsWithChildren) => { load(true); } } - }, [getFormMethods, request]); + }, [getFormMethods, request, opened]); return ( <> diff --git a/src/Former/FormerResolveSpecAPI.ts b/src/Former/FormerResolveSpecAPI.ts new file mode 100644 index 0000000..512376d --- /dev/null +++ b/src/Former/FormerResolveSpecAPI.ts @@ -0,0 +1,72 @@ +import type { FormerAPICallType } from './Former.types'; + +interface ResolveSpecRequest { + operation: 'read' | 'create' | 'update' | 'delete'; + data?: Record; + options?: { + preload?: string[]; + columns?: string[]; + filters?: Array<{ column: string; operator: string; value: any }>; + sort?: string[]; + limit?: number; + offset?: number; + customOperators?: any[]; + computedColumns?: any[]; + }; +} + +function FormerResolveSpecAPI(options: { + url: string; + authToken: string; + signal?: AbortSignal; + fetchOptions?: Partial; +}): FormerAPICallType { + return async (mode, request, value, key) => { + const baseUrl = options.url.replace(/\/$/, ''); + + // Build URL: /[schema]/[table_or_entity]/[id] + let url = `${baseUrl}`; + if (request !== 'insert' && key) { + url = `${url}/${key}`; + } + + // Build ResolveSpec request body + const resolveSpecRequest: ResolveSpecRequest = { + operation: + mode === 'read' + ? 'read' + : request === 'delete' + ? 'delete' + : request === 'update' + ? 'update' + : 'create', + }; + + if (mode === 'mutate') { + resolveSpecRequest.data = value; + } + + const fetchOptions: RequestInit = { + cache: 'no-cache', + signal: options.signal, + ...options.fetchOptions, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${options.authToken}`, + ...options.fetchOptions?.headers, + }, + body: JSON.stringify(resolveSpecRequest), + }; + + const response = await fetch(url, fetchOptions); + if (!response.ok) { + throw new Error(`API request failed with status ${response.status}`); + } + + const data = await response.json(); + return data as any; + }; +} + +export { FormerResolveSpecAPI, type ResolveSpecRequest }; diff --git a/src/Former/FormerRestHeadSpecAPI.ts b/src/Former/FormerRestHeadSpecAPI.ts new file mode 100644 index 0000000..a4a0d2d --- /dev/null +++ b/src/Former/FormerRestHeadSpecAPI.ts @@ -0,0 +1,50 @@ +import type { FormerAPICallType } from './Former.types'; + +function FormerRestHeadSpecAPI(options: { + url: string; + authToken: string; + signal?: AbortSignal; + fetchOptions?: Partial; +}): FormerAPICallType { + return async (mode, request, value, key) => { + const baseUrl = options.url ?? ''; // Remove trailing slashes + let url = baseUrl; + let fetchOptions: RequestInit = { + cache: 'no-cache', + signal: options.signal, + ...options.fetchOptions, + method: + mode === 'read' + ? 'GET' + : request === 'delete' + ? 'DELETE' + : request === 'update' + ? 'PUT' + : 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${options.authToken}`, + ...options.fetchOptions?.headers, + }, + body: mode === 'mutate' && request !== 'delete' ? JSON.stringify(value) : undefined, + }; + + if (request !== 'insert') { + url = `${baseUrl}/${key}`; + } + + const response = await fetch(url, fetchOptions); + if (!response.ok) { + throw new Error(`API request failed with status ${response.status}`); + } + + if (mode === 'read') { + const data = await response.json(); + return data as any; + } else { + return value as any; + } + }; +} + +export { FormerRestHeadSpecAPI }; diff --git a/src/Former/FormerWrappers.tsx b/src/Former/FormerWrappers.tsx new file mode 100644 index 0000000..7e527c7 --- /dev/null +++ b/src/Former/FormerWrappers.tsx @@ -0,0 +1,114 @@ +import { + Drawer, + Modal, + Popover, + type DrawerProps, + type ModalProps, + type PopoverProps, +} from '@mantine/core'; +import type { FormerProps } from './Former.types'; +import { Former } from './Former'; + +export const FormerDialog = (props: DrawerProps & { former: FormerProps }) => { + const { former, children, opened, onClose, ...rest } = props; + return ( + { + const values = getState('values'); + const request = getState('request'); + const uniqueKeyField = getState('uniqueKeyField') ?? 'id'; + return ( + onClose?.()} + opened={opened ?? false} + > + {children} + + ); + }} + > + {children} + + ); +}; + +export const FormerModel = (props: ModalProps & { former: FormerProps }) => { + const { former, children, opened, onClose, ...rest } = props; + return ( + { + const values = getState('values'); + const request = getState('request'); + const uniqueKeyField = getState('uniqueKeyField') ?? 'id'; + return ( + onClose?.()} + opened={opened ?? false} + > + {children} + + ); + }} + > + {children} + + ); +}; + +export const FormerPopover = ( + props: PopoverProps & { former: FormerProps; target: React.ReactNode } +) => { + const { former, children, opened, onClose, target, ...rest } = props; + return ( + { + return ( + onClose?.()} + opened={opened ?? false} + > + {target} + {children} + + ); + }} + > + {children} + + ); +}; diff --git a/src/Former/stories/apiFormData.tsx b/src/Former/stories/apiFormData.tsx new file mode 100644 index 0000000..d27f7f4 --- /dev/null +++ b/src/Former/stories/apiFormData.tsx @@ -0,0 +1,40 @@ +import { TextInput } from '@mantine/core'; +import { Former } from '../Former'; +import { useUncontrolled } from '@mantine/hooks'; + +import { Controller } from 'react-hook-form'; + +export const ApiFormData = (props: { + values?: Record; + onChange?: (values: Record) => void; + primeData?: Record; +}) => { + const [values, setValues] = useUncontrolled>({ + value: props.values, + defaultValue: { url: '', authToken: '', ...props.primeData }, + finalValue: { url: '', authToken: '', ...props.primeData }, + onChange: props.onChange, + }); + + return ( + + } + /> + } + /> + + ); +}; diff --git a/src/Former/stories/example.tsx b/src/Former/stories/example.tsx index 68d5fda..e5fe511 100644 --- a/src/Former/stories/example.tsx +++ b/src/Former/stories/example.tsx @@ -2,14 +2,38 @@ import { Button, Drawer, Group, Paper, Select, Stack, Switch } from '@mantine/co import { useRef, useState } from 'react'; import { Controller } from 'react-hook-form'; -import type { FormerProps, FormerRef } from '../Former.types'; +import type { FormerAPICallType, FormerProps, FormerRef } from '../Former.types'; import { Former } from '../Former'; +import { ApiFormData } from './apiFormData'; +import { FormerRestHeadSpecAPI } from '../FormerRestHeadSpecAPI'; +import { FormerDialog, FormerModel } from '../FormerWrappers'; + +const StubAPI = (): FormerAPICallType => (mode, request, value) => { + console.log('API Call', mode, request, value); + if (mode === 'read') { + return new Promise((resolve) => { + setTimeout(() => { + resolve({ a: 'Another Value', test: 'Loaded Value' }); + }, 1000); + }); + } + return new Promise((resolve) => { + setTimeout(() => { + resolve(value || {}); + }, 1000); + }); +}; export const FormTest = () => { const [request, setRequest] = useState('insert'); const [wrapped, setWrapped] = useState(false); const [disableHTML, setDisableHTML] = useState(false); + const [apiOptions, setApiOptions] = useState({ + url: '', + authToken: '', + type: '', + }); const [layout, setLayout] = useState({ buttonOnTop: false, title: 'Custom Former Title', @@ -17,8 +41,8 @@ export const FormTest = () => { } as FormerProps['layout']); const [open, setOpen] = useState(false); - const [formData, setFormData] = useState({ a: 99 }); - console.log('formData', formData); + const [formData, setFormData] = useState({ a: 99, rid_usernote: 3047 }); + //console.log('formData render', formData); const ref = useRef(null); return ( @@ -44,6 +68,13 @@ export const FormTest = () => { label="Button On Top" onChange={(event) => setLayout({ ...layout, buttonOnTop: event.currentTarget.checked })} /> + + setApiOptions({ ...apiOptions, type: event.currentTarget.checked ? 'api' : '' }) + } + /> @@ -65,25 +96,21 @@ export const FormTest = () => { Test Show/Hide + setOpen(false)} former={{ request: 'insert' }}> +
Test
+
{children}
} //opened={true} - apiKeyField="a" - onAPICall={(mode, request, value) => { - console.log('API Call', mode, request, value); - if (mode === 'read') { - return new Promise((resolve) => { - setTimeout(() => { - resolve({ a: 'Another Value', test: 'Loaded Value' }); - }, 1000); - }); - } - return new Promise((resolve) => { - setTimeout(() => { - resolve(value || {}); - }, 1000); - }); - }} + uniqueKeyField="rid_usernote" + onAPICall={ + apiOptions.type === 'api' + ? FormerRestHeadSpecAPI({ + authToken: apiOptions.authToken, + url: apiOptions.url, + }) + : StubAPI() + } disableHTMlForm={disableHTML} onChange={setFormData} onClose={() => setOpen(false)} @@ -94,29 +121,28 @@ export const FormTest = () => { useFormProps={{ criteriaMode: 'all', shouldUseNativeValidation: false }} values={formData} layout={layout} - wrapper={ - wrapped - ? (children, opened, onClose, _onOpen, getState) => { - const values = getState('values'); - return ( - onClose?.()} - opened={opened ?? false} - title={`Drawer Former - Current A Value: ${values?.a}`} - w={'50%'} - > - - {children} - - - - ); - } - : undefined - } + // wrapper={ + // wrapped + // ? (children, opened, onClose, _onOpen, getState) => { + // const values = getState('values'); + // return ( + // onClose?.()} + // opened={opened ?? false} + // title={`Drawer Former - Current A Value: ${values?.a}`} + // w={'50%'} + // > + // + // {children} + // + // + // ); + // } + // : undefined + // } > - + { render={({ field }) => } rules={{ required: 'Field is required' }} /> + } + rules={{ required: 'Field is required' }} + /> {!disableHTML && ( @@ -136,6 +167,14 @@ export const FormTest = () => { )}
+ {apiOptions.type === 'api' && ( + { + setApiOptions({ ...apiOptions, ...values }); + }} + /> + )} ); }; diff --git a/src/Former/todo.md b/src/Former/todo.md index ed5c59c..db7f52b 100644 --- a/src/Former/todo.md +++ b/src/Former/todo.md @@ -1,7 +1,7 @@ - [x] Wrapper must receive button areas etc. Better scroll areas. -- [ ] Predefined wrappers (Model,Dialog,notification,popover) -- [ ] Headerspec API -- [ ] Relspec API +- [x] Predefined wrappers (Model,Dialog,notification,popover) +- [x] Headerspec API +- [x] Relspec API - [ ] SocketSpec API - [x] Layout Tool - [x] Header Section