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

View File

@@ -78,11 +78,20 @@ const FormerInner = forwardRef<FormerRef<any>, Partial<FormerProps<any>> & 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 (

View File

@@ -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<T extends FieldValues = any> = (
@@ -14,20 +15,22 @@ export type FormerSectionRender<T extends FieldValues = any> = (
getState: FormerState<T>['getState']
) => 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> {
id?: string;
afterGet?: (data: T) => Promise<T> | void;
afterSave?: (data: T) => Promise<void> | void;
apiKeyField?: string;
uniqueKeyField?: string;
beforeSave?: (data: T) => Promise<T> | T;
disableHTMlForm?: boolean;
keepOpen?: boolean;
onAPICall?: (
mode: 'mutate' | 'read',
request: RequestType,
value?: T,
key?: number | string
) => Promise<T>;
onAPICall?: FormerAPICallType<T>;
onCancel?: () => void;
onChange?: (value: T) => void;
onClose?: (data?: T) => void;
@@ -35,6 +38,7 @@ export interface FormerProps<T extends FieldValues = any> {
onOpen?: (data?: T) => void;
opened?: boolean;
dirty?: boolean;
primeData?: T;
request: RequestType;
useFormProps?: UseFormProps<T>;
@@ -44,6 +48,8 @@ export interface FormerProps<T extends FieldValues = any> {
layout?: {
renderTop?: FormerSectionRender<T>;
renderBottom?: FormerSectionRender<T>;
saveButtonTitle?: React.ReactNode;
closeButtonTitle?: React.ReactNode;
saveButtonProps?: ButtonProps;
closeButtonProps?: ButtonProps;
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 { 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 (
<Group
justify="center"
@@ -24,22 +43,41 @@ export const FormerButtonArea = () => {
leftSection={<IconX />}
size="sm"
px="md"
onClick={() => onClose()}
miw={'8rem'}
{...closeButtonProps}
onClick={() => {
onClose();
}}
>
Close
{closeButtonTitle || 'Close'}
</Button>
)}
<Button
color="green"
leftSection={<IconDeviceFloppy />}
size="sm"
px="md"
onClick={() => save()}
miw={'8rem'}
<Tooltip
label={
disabledSave ? (
<p>
Cannot save in view or select mode, or no changes made. <br />
Try changing some values.
</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>
);

View File

@@ -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 (
<>

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 { 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<null | string>('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<FormerRef>(null);
return (
@@ -44,6 +68,13 @@ export const FormTest = () => {
label="Button On Top"
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>
<Button onClick={() => setOpen(true)}>Open Former Drawer</Button>
<Group>
@@ -65,25 +96,21 @@ export const FormTest = () => {
Test Show/Hide
</Button>
</Group>
<FormerModel opened={open} onClose={() => setOpen(false)} former={{ request: 'insert' }}>
<div>Test</div>
</FormerModel>
<Former
//wrapper={(children, getState) => <div>{children}</div>}
//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 (
<Drawer
h={'100%'}
onClose={() => onClose?.()}
opened={opened ?? false}
title={`Drawer Former - Current A Value: ${values?.a}`}
w={'50%'}
>
<Paper h="100%" shadow="sm" w="100%" withBorder>
{children}
<Button>Test Save</Button>
</Paper>
</Drawer>
);
}
: undefined
}
// wrapper={
// wrapped
// ? (children, opened, onClose, _onOpen, getState) => {
// const values = getState('values');
// return (
// <Drawer
// h={'100%'}
// onClose={() => onClose?.()}
// opened={opened ?? false}
// title={`Drawer Former - Current A Value: ${values?.a}`}
// w={'50%'}
// >
// <Paper h="100%" shadow="sm" w="100%" withBorder>
// {children}
// </Paper>
// </Drawer>
// );
// }
// : undefined
// }
>
<Stack h="1200px">
<Stack pb={'400px'}>
<Stack>
<Controller
name="test"
@@ -127,6 +153,11 @@ export const FormTest = () => {
render={({ field }) => <input type="text" {...field} placeholder="B" />}
rules={{ required: 'Field is required' }}
/>
<Controller
name="note"
render={({ field }) => <input type="text" {...field} placeholder="note" />}
rules={{ required: 'Field is required' }}
/>
</Stack>
{!disableHTML && (
<Stack>
@@ -136,6 +167,14 @@ export const FormTest = () => {
)}
</Stack>
</Former>
{apiOptions.type === 'api' && (
<ApiFormData
values={apiOptions}
onChange={(values) => {
setApiOptions({ ...apiOptions, ...values });
}}
/>
)}
</Stack>
);
};

View File

@@ -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