feat(form): enhance form functionality and API integration

* 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
This commit is contained in:
2026-01-14 21:51:39 +02:00
parent e6507f44af
commit cd2f6db880
11 changed files with 462 additions and 74 deletions

View File

@@ -16,7 +16,7 @@ const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore<
load: async (reset?: boolean) => { load: async (reset?: boolean) => {
try { try {
set({ loading: true }); 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]; const keyValue = (get().values as any)?.[keyName] ?? (get().primeData as any)?.[keyName];
if (get().onAPICall && keyValue !== undefined) { if (get().onAPICall && keyValue !== undefined) {
let data = await get().onAPICall!( let data = await get().onAPICall!(
@@ -98,7 +98,7 @@ const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore<
} }
if (get().onAPICall) { if (get().onAPICall) {
const keyName = get()?.apiKeyField || 'id'; const keyName = get()?.uniqueKeyField || 'id';
const keyValue = const keyValue =
(get().values as any)?.[keyName] ?? (get().primeData as any)?.[keyName]; (get().values as any)?.[keyName] ?? (get().primeData as any)?.[keyName];
const savedData = await get().onAPICall!( const savedData = await get().onAPICall!(
@@ -112,6 +112,7 @@ const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore<
} }
set({ loading: false, values: savedData }); set({ loading: false, values: savedData });
get().onChange?.(savedData); get().onChange?.(savedData);
formMethods.reset(savedData); //reset with saved data to clear dirty state
if (!keepOpen) { if (!keepOpen) {
get().onClose?.(savedData); get().onClose?.(savedData);
} }
@@ -119,6 +120,7 @@ const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore<
} }
set({ loading: false, values: data }); set({ loading: false, values: data });
formMethods.reset(data); //reset with saved data to clear dirty state
get().onChange?.(data); get().onChange?.(data);
if (!keepOpen) { if (!keepOpen) {
get().onClose?.(data); get().onClose?.(data);
@@ -169,7 +171,7 @@ const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore<
}, },
values: undefined, values: undefined,
}), }),
({ onConfirmDelete, primeData, request, values, id }) => { ({ onConfirmDelete, primeData, request, values, id, onClose, useStoreApi }) => {
let _onConfirmDelete = onConfirmDelete; let _onConfirmDelete = onConfirmDelete;
if (!onConfirmDelete) { if (!onConfirmDelete) {
_onConfirmDelete = async () => { _onConfirmDelete = async () => {
@@ -180,9 +182,28 @@ const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore<
return { return {
onConfirmDelete: _onConfirmDelete, onConfirmDelete: _onConfirmDelete,
primeData, primeData,
request: request || 'insert', request: (request || 'insert').replace('change', 'update'),
values: { ...primeData, ...values }, values: { ...primeData, ...values },
id: !id ? newUUID() : id, 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);
}
}
},
}; };
} }
); );

View File

@@ -78,11 +78,20 @@ const FormerInner = forwardRef<FormerRef<any>, Partial<FormerProps<any>> & Props
return await validate(); return await validate();
}, },
}), }),
[getState, onChange] [getState, onChange, validate, save, reset, setState, onClose, onOpen]
); );
useEffect(() => { useEffect(() => {
setState('getFormMethods', () => formMethods); setState('getFormMethods', () => formMethods);
if (formMethods) {
formMethods.subscribe({
formState: { isDirty: true },
callback: ({ isDirty }) => {
setState('dirty', isDirty);
},
});
}
}, [formMethods]); }, [formMethods]);
return ( return (

View File

@@ -4,6 +4,7 @@ import type {
LoadingOverlayProps, LoadingOverlayProps,
ScrollAreaAutosizeProps, ScrollAreaAutosizeProps,
} from '@mantine/core'; } from '@mantine/core';
import type React from 'react';
import type { FieldValues, UseFormProps, UseFormReturn } from 'react-hook-form'; import type { FieldValues, UseFormProps, UseFormReturn } from 'react-hook-form';
export type FormerSectionRender<T extends FieldValues = any> = ( export type FormerSectionRender<T extends FieldValues = any> = (
@@ -14,20 +15,22 @@ export type FormerSectionRender<T extends FieldValues = any> = (
getState: FormerState<T>['getState'] getState: FormerState<T>['getState']
) => React.ReactNode; ) => React.ReactNode;
export type FormerAPICallType<T extends FieldValues = any> = (
mode: 'mutate' | 'read',
request: RequestType,
value?: T,
key?: number | string
) => Promise<T>;
export interface FormerProps<T extends FieldValues = any> { export interface FormerProps<T extends FieldValues = any> {
id?: string; id?: string;
afterGet?: (data: T) => Promise<T> | void; afterGet?: (data: T) => Promise<T> | void;
afterSave?: (data: T) => Promise<void> | void; afterSave?: (data: T) => Promise<void> | void;
apiKeyField?: string; uniqueKeyField?: string;
beforeSave?: (data: T) => Promise<T> | T; beforeSave?: (data: T) => Promise<T> | T;
disableHTMlForm?: boolean; disableHTMlForm?: boolean;
keepOpen?: boolean; keepOpen?: boolean;
onAPICall?: ( onAPICall?: FormerAPICallType<T>;
mode: 'mutate' | 'read',
request: RequestType,
value?: T,
key?: number | string
) => Promise<T>;
onCancel?: () => void; onCancel?: () => void;
onChange?: (value: T) => void; onChange?: (value: T) => void;
onClose?: (data?: T) => void; onClose?: (data?: T) => void;
@@ -35,6 +38,7 @@ export interface FormerProps<T extends FieldValues = any> {
onOpen?: (data?: T) => void; onOpen?: (data?: T) => void;
opened?: boolean; opened?: boolean;
dirty?: boolean;
primeData?: T; primeData?: T;
request: RequestType; request: RequestType;
useFormProps?: UseFormProps<T>; useFormProps?: UseFormProps<T>;
@@ -44,6 +48,8 @@ export interface FormerProps<T extends FieldValues = any> {
layout?: { layout?: {
renderTop?: FormerSectionRender<T>; renderTop?: FormerSectionRender<T>;
renderBottom?: FormerSectionRender<T>; renderBottom?: FormerSectionRender<T>;
saveButtonTitle?: React.ReactNode;
closeButtonTitle?: React.ReactNode;
saveButtonProps?: ButtonProps; saveButtonProps?: ButtonProps;
closeButtonProps?: ButtonProps; closeButtonProps?: ButtonProps;
buttonOnTop?: boolean; buttonOnTop?: boolean;

View File

@@ -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 { IconX, IconDeviceFloppy } from '@tabler/icons-react';
import { useFormerStore } from './Former.store'; import { useFormerStore } from './Former.store';
export const FormerButtonArea = () => { export const FormerButtonArea = () => {
const { save, onClose, buttonAreaGroupProps } = useFormerStore((state) => ({ const {
save,
onClose,
buttonAreaGroupProps,
saveButtonProps,
closeButtonProps,
closeButtonTitle,
saveButtonTitle,
request,
dirty,
} = useFormerStore((state) => ({
save: state.save, save: state.save,
onClose: state.onClose, onClose: state.onClose,
buttonAreaGroupProps: state.layout?.buttonAreaGroupProps, 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 ( return (
<Group <Group
justify="center" justify="center"
@@ -24,22 +43,41 @@ export const FormerButtonArea = () => {
leftSection={<IconX />} leftSection={<IconX />}
size="sm" size="sm"
px="md" px="md"
onClick={() => onClose()}
miw={'8rem'} miw={'8rem'}
{...closeButtonProps}
onClick={() => {
onClose();
}}
> >
Close {closeButtonTitle || 'Close'}
</Button> </Button>
)} )}
<Button <Tooltip
color="green" label={
leftSection={<IconDeviceFloppy />} disabledSave ? (
size="sm" <p>
px="md" Cannot save in view or select mode, or no changes made. <br />
onClick={() => save()} Try changing some values.
miw={'8rem'} </p>
) : (
<p>Save the current record</p>
)
}
> >
Save <Button
</Button> color="green"
leftSection={<IconDeviceFloppy />}
size="sm"
px="md"
miw={'8rem'}
bg={request === 'delete' ? 'red' : undefined}
{...saveButtonProps}
disabled={disabledSave}
onClick={() => save()}
>
{saveButtonTitle || 'Save'}
</Button>
</Tooltip>
</Group> </Group>
</Group> </Group>
); );

View File

@@ -17,8 +17,7 @@ export const FormerLayout = (props: PropsWithChildren) => {
save, save,
scrollAreaProps, scrollAreaProps,
id, id,
layout, opened,
getState,
} = useFormerStore((state) => ({ } = useFormerStore((state) => ({
disableHTMlForm: state.disableHTMlForm, disableHTMlForm: state.disableHTMlForm,
getFormMethods: state.getFormMethods, getFormMethods: state.getFormMethods,
@@ -30,8 +29,8 @@ export const FormerLayout = (props: PropsWithChildren) => {
save: state.save, save: state.save,
scrollAreaProps: state.scrollAreaProps, scrollAreaProps: state.scrollAreaProps,
id: state.id, id: state.id,
layout: state.layout,
getState: state.getState, opened: state.opened,
})); }));
useEffect(() => { useEffect(() => {
@@ -41,7 +40,7 @@ export const FormerLayout = (props: PropsWithChildren) => {
load(true); load(true);
} }
} }
}, [getFormMethods, request]); }, [getFormMethods, request, opened]);
return ( return (
<> <>

View File

@@ -0,0 +1,72 @@
import type { FormerAPICallType } from './Former.types';
interface ResolveSpecRequest {
operation: 'read' | 'create' | 'update' | 'delete';
data?: Record<string, any>;
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<RequestInit>;
}): 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 };

View File

@@ -0,0 +1,50 @@
import type { FormerAPICallType } from './Former.types';
function FormerRestHeadSpecAPI(options: {
url: string;
authToken: string;
signal?: AbortSignal;
fetchOptions?: Partial<RequestInit>;
}): 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 };

View File

@@ -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 (
<Former
{...former}
opened={opened}
onClose={onClose}
wrapper={(children, opened, onClose, _onOpen, getState) => {
const values = getState('values');
const request = getState('request');
const uniqueKeyField = getState('uniqueKeyField') ?? 'id';
return (
<Drawer
h={'100%'}
title={
request === 'delete'
? `Delete Record - ${values?.[uniqueKeyField]}`
: request === 'insert'
? 'New Record'
: `Edit Record - ${values?.[uniqueKeyField]}`
}
closeOnClickOutside={false}
{...rest}
onClose={() => onClose?.()}
opened={opened ?? false}
>
{children}
</Drawer>
);
}}
>
{children}
</Former>
);
};
export const FormerModel = (props: ModalProps & { former: FormerProps }) => {
const { former, children, opened, onClose, ...rest } = props;
return (
<Former
{...former}
opened={opened}
onClose={onClose}
wrapper={(children, opened, onClose, _onOpen, getState) => {
const values = getState('values');
const request = getState('request');
const uniqueKeyField = getState('uniqueKeyField') ?? 'id';
return (
<Modal
h={'100%'}
title={
request === 'delete'
? `Delete Record - ${values?.[uniqueKeyField]}`
: request === 'insert'
? 'New Record'
: `Edit Record - ${values?.[uniqueKeyField]}`
}
closeOnClickOutside={false}
{...rest}
onClose={() => onClose?.()}
opened={opened ?? false}
>
{children}
</Modal>
);
}}
>
{children}
</Former>
);
};
export const FormerPopover = (
props: PopoverProps & { former: FormerProps; target: React.ReactNode }
) => {
const { former, children, opened, onClose, target, ...rest } = props;
return (
<Former
{...former}
opened={opened}
onClose={onClose}
wrapper={(children, opened, onClose, _onOpen, _getState) => {
return (
<Popover
withArrow
closeOnClickOutside={false}
width={250}
trapFocus
middlewares={{ inline: true }}
{...rest}
onClose={() => onClose?.()}
opened={opened ?? false}
>
<Popover.Target>{target}</Popover.Target>
<Popover.Dropdown>{children}</Popover.Dropdown>
</Popover>
);
}}
>
{children}
</Former>
);
};

View File

@@ -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<string, unknown>;
onChange?: (values: Record<string, unknown>) => void;
primeData?: Record<string, unknown>;
}) => {
const [values, setValues] = useUncontrolled<Record<string, unknown>>({
value: props.values,
defaultValue: { url: '', authToken: '', ...props.primeData },
finalValue: { url: '', authToken: '', ...props.primeData },
onChange: props.onChange,
});
return (
<Former
request="update"
uniqueKeyField="id"
disableHTMlForm
primeData={props.primeData}
values={values}
onChange={setValues}
layout={{ saveButtonTitle: 'Save URL Parameters' }}
id="api-form-data"
>
<Controller
name="url"
render={({ field }) => <TextInput type="url" label="URL" {...field} />}
/>
<Controller
name="authToken"
render={({ field }) => <TextInput type="password" label="Auth Token" {...field} />}
/>
</Former>
);
};

View File

@@ -2,14 +2,38 @@ import { Button, Drawer, Group, Paper, Select, Stack, Switch } from '@mantine/co
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { Controller } from 'react-hook-form'; 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 { 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 = () => { export const FormTest = () => {
const [request, setRequest] = useState<null | string>('insert'); const [request, setRequest] = useState<null | string>('insert');
const [wrapped, setWrapped] = useState(false); const [wrapped, setWrapped] = useState(false);
const [disableHTML, setDisableHTML] = useState(false); const [disableHTML, setDisableHTML] = useState(false);
const [apiOptions, setApiOptions] = useState({
url: '',
authToken: '',
type: '',
});
const [layout, setLayout] = useState({ const [layout, setLayout] = useState({
buttonOnTop: false, buttonOnTop: false,
title: 'Custom Former Title', title: 'Custom Former Title',
@@ -17,8 +41,8 @@ export const FormTest = () => {
} as FormerProps['layout']); } as FormerProps['layout']);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [formData, setFormData] = useState({ a: 99 }); const [formData, setFormData] = useState({ a: 99, rid_usernote: 3047 });
console.log('formData', formData); //console.log('formData render', formData);
const ref = useRef<FormerRef>(null); const ref = useRef<FormerRef>(null);
return ( return (
@@ -44,6 +68,13 @@ export const FormTest = () => {
label="Button On Top" label="Button On Top"
onChange={(event) => setLayout({ ...layout, buttonOnTop: event.currentTarget.checked })} onChange={(event) => setLayout({ ...layout, buttonOnTop: event.currentTarget.checked })}
/> />
<Switch
checked={apiOptions.type === 'api'}
label="Use API"
onChange={(event) =>
setApiOptions({ ...apiOptions, type: event.currentTarget.checked ? 'api' : '' })
}
/>
</Group> </Group>
<Button onClick={() => setOpen(true)}>Open Former Drawer</Button> <Button onClick={() => setOpen(true)}>Open Former Drawer</Button>
<Group> <Group>
@@ -65,25 +96,21 @@ export const FormTest = () => {
Test Show/Hide Test Show/Hide
</Button> </Button>
</Group> </Group>
<FormerModel opened={open} onClose={() => setOpen(false)} former={{ request: 'insert' }}>
<div>Test</div>
</FormerModel>
<Former <Former
//wrapper={(children, getState) => <div>{children}</div>} //wrapper={(children, getState) => <div>{children}</div>}
//opened={true} //opened={true}
apiKeyField="a" uniqueKeyField="rid_usernote"
onAPICall={(mode, request, value) => { onAPICall={
console.log('API Call', mode, request, value); apiOptions.type === 'api'
if (mode === 'read') { ? FormerRestHeadSpecAPI({
return new Promise((resolve) => { authToken: apiOptions.authToken,
setTimeout(() => { url: apiOptions.url,
resolve({ a: 'Another Value', test: 'Loaded Value' }); })
}, 1000); : StubAPI()
}); }
}
return new Promise((resolve) => {
setTimeout(() => {
resolve(value || {});
}, 1000);
});
}}
disableHTMlForm={disableHTML} disableHTMlForm={disableHTML}
onChange={setFormData} onChange={setFormData}
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
@@ -94,29 +121,28 @@ export const FormTest = () => {
useFormProps={{ criteriaMode: 'all', shouldUseNativeValidation: false }} useFormProps={{ criteriaMode: 'all', shouldUseNativeValidation: false }}
values={formData} values={formData}
layout={layout} layout={layout}
wrapper={ // wrapper={
wrapped // wrapped
? (children, opened, onClose, _onOpen, getState) => { // ? (children, opened, onClose, _onOpen, getState) => {
const values = getState('values'); // const values = getState('values');
return ( // return (
<Drawer // <Drawer
h={'100%'} // h={'100%'}
onClose={() => onClose?.()} // onClose={() => onClose?.()}
opened={opened ?? false} // opened={opened ?? false}
title={`Drawer Former - Current A Value: ${values?.a}`} // title={`Drawer Former - Current A Value: ${values?.a}`}
w={'50%'} // w={'50%'}
> // >
<Paper h="100%" shadow="sm" w="100%" withBorder> // <Paper h="100%" shadow="sm" w="100%" withBorder>
{children} // {children}
<Button>Test Save</Button> // </Paper>
</Paper> // </Drawer>
</Drawer> // );
); // }
} // : undefined
: undefined // }
}
> >
<Stack h="1200px"> <Stack pb={'400px'}>
<Stack> <Stack>
<Controller <Controller
name="test" name="test"
@@ -127,6 +153,11 @@ export const FormTest = () => {
render={({ field }) => <input type="text" {...field} placeholder="B" />} render={({ field }) => <input type="text" {...field} placeholder="B" />}
rules={{ required: 'Field is required' }} rules={{ required: 'Field is required' }}
/> />
<Controller
name="note"
render={({ field }) => <input type="text" {...field} placeholder="note" />}
rules={{ required: 'Field is required' }}
/>
</Stack> </Stack>
{!disableHTML && ( {!disableHTML && (
<Stack> <Stack>
@@ -136,6 +167,14 @@ export const FormTest = () => {
)} )}
</Stack> </Stack>
</Former> </Former>
{apiOptions.type === 'api' && (
<ApiFormData
values={apiOptions}
onChange={(values) => {
setApiOptions({ ...apiOptions, ...values });
}}
/>
)}
</Stack> </Stack>
); );
}; };

View File

@@ -1,7 +1,7 @@
- [x] Wrapper must receive button areas etc. Better scroll areas. - [x] Wrapper must receive button areas etc. Better scroll areas.
- [ ] Predefined wrappers (Model,Dialog,notification,popover) - [x] Predefined wrappers (Model,Dialog,notification,popover)
- [ ] Headerspec API - [x] Headerspec API
- [ ] Relspec API - [x] Relspec API
- [ ] SocketSpec API - [ ] SocketSpec API
- [x] Layout Tool - [x] Layout Tool
- [x] Header Section - [x] Header Section