feat(form): enhance form layout and functionality

* Add FormerButtonArea component for action buttons
* Introduce FormerLayoutTop and FormerLayoutBottom for structured layout
* Update Former types to include new properties
* Implement dynamic ID generation for forms
* Refactor example to demonstrate new layout features
* Mark tasks as completed in todo.md
This commit is contained in:
2026-01-14 19:35:38 +02:00
parent 400a193a58
commit e6507f44af
8 changed files with 194 additions and 63 deletions

View File

@@ -2,6 +2,7 @@ import { createSyncStore } from '@warkypublic/zustandsyncstore';
import { produce } from 'immer'; import { produce } from 'immer';
import type { FormerProps, FormerState } from './Former.types'; import type { FormerProps, FormerState } from './Former.types';
import { newUUID } from '@warkypublic/artemis-kit';
const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore< const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore<
FormerState<any> & Partial<FormerProps<any>>, FormerState<any> & Partial<FormerProps<any>>,
@@ -168,18 +169,20 @@ const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore<
}, },
values: undefined, values: undefined,
}), }),
({ onConfirmDelete, primeData, request, values }) => { ({ onConfirmDelete, primeData, request, values, id }) => {
let _onConfirmDelete = onConfirmDelete; let _onConfirmDelete = onConfirmDelete;
if (!onConfirmDelete) { if (!onConfirmDelete) {
_onConfirmDelete = async () => { _onConfirmDelete = async () => {
return confirm('Are you sure you want to delete this item?'); return confirm('Are you sure you want to delete this item?');
}; };
} }
return { return {
onConfirmDelete: _onConfirmDelete, onConfirmDelete: _onConfirmDelete,
primeData, primeData,
request: request || 'insert', request: request || 'insert',
values: { ...primeData, ...values }, values: { ...primeData, ...values },
id: !id ? newUUID() : id,
}; };
} }
); );

View File

@@ -1,7 +1,21 @@
import type { LoadingOverlayProps, ScrollAreaAutosizeProps } from '@mantine/core'; import type {
ButtonProps,
GroupProps,
LoadingOverlayProps,
ScrollAreaAutosizeProps,
} from '@mantine/core';
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> = (
children: React.ReactNode,
opened: boolean | undefined,
onClose: ((data?: T) => void) | undefined,
onOpen: ((data?: T) => void) | undefined,
getState: FormerState<T>['getState']
) => React.ReactNode;
export interface FormerProps<T extends FieldValues = any> { export interface FormerProps<T extends FieldValues = any> {
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; apiKeyField?: string;
@@ -25,13 +39,17 @@ export interface FormerProps<T extends FieldValues = any> {
request: RequestType; request: RequestType;
useFormProps?: UseFormProps<T>; useFormProps?: UseFormProps<T>;
values?: T; values?: T;
wrapper?: ( wrapper?: FormerSectionRender<T>;
children: React.ReactNode,
opened: boolean | undefined, layout?: {
onClose: ((data?: T) => void) | undefined, renderTop?: FormerSectionRender<T>;
onOpen: ((data?: T) => void) | undefined, renderBottom?: FormerSectionRender<T>;
getState: <K extends keyof FormStateAndProps<T>>(key: K) => FormStateAndProps<T>[K] saveButtonProps?: ButtonProps;
) => React.ReactNode; closeButtonProps?: ButtonProps;
buttonOnTop?: boolean;
buttonAreaGroupProps?: GroupProps;
title?: string;
};
} }
export interface FormerRef<T extends FieldValues = any> { export interface FormerRef<T extends FieldValues = any> {

View File

@@ -0,0 +1,46 @@
import { Group, Button } from '@mantine/core';
import { IconX, IconDeviceFloppy } from '@tabler/icons-react';
import { useFormerStore } from './Former.store';
export const FormerButtonArea = () => {
const { save, onClose, buttonAreaGroupProps } = useFormerStore((state) => ({
save: state.save,
onClose: state.onClose,
buttonAreaGroupProps: state.layout?.buttonAreaGroupProps,
}));
return (
<Group
justify="center"
w="100%"
p="xs"
style={{ boxShadow: '2px 2px 5px rgba(47, 47, 47, 0.1)' }}
{...buttonAreaGroupProps}
>
<Group justify="space-evenly" grow>
{typeof onClose === 'function' && (
<Button
color="orange"
leftSection={<IconX />}
size="sm"
px="md"
onClick={() => onClose()}
miw={'8rem'}
>
Close
</Button>
)}
<Button
color="green"
leftSection={<IconDeviceFloppy />}
size="sm"
px="md"
onClick={() => save()}
miw={'8rem'}
>
Save
</Button>
</Group>
</Group>
);
};

View File

@@ -2,6 +2,8 @@ import { LoadingOverlay, ScrollAreaAutosize } from '@mantine/core';
import { type PropsWithChildren, useEffect } from 'react'; import { type PropsWithChildren, useEffect } from 'react';
import { useFormerStore } from './Former.store'; import { useFormerStore } from './Former.store';
import { FormerLayoutBottom } from './FormerLayoutBottom';
import { FormerLayoutTop } from './FormerLayoutTop';
export const FormerLayout = (props: PropsWithChildren) => { export const FormerLayout = (props: PropsWithChildren) => {
const { const {
@@ -14,6 +16,9 @@ export const FormerLayout = (props: PropsWithChildren) => {
reset, reset,
save, save,
scrollAreaProps, scrollAreaProps,
id,
layout,
getState,
} = useFormerStore((state) => ({ } = useFormerStore((state) => ({
disableHTMlForm: state.disableHTMlForm, disableHTMlForm: state.disableHTMlForm,
getFormMethods: state.getFormMethods, getFormMethods: state.getFormMethods,
@@ -24,6 +29,9 @@ export const FormerLayout = (props: PropsWithChildren) => {
reset: state.reset, reset: state.reset,
save: state.save, save: state.save,
scrollAreaProps: state.scrollAreaProps, scrollAreaProps: state.scrollAreaProps,
id: state.id,
layout: state.layout,
getState: state.getState,
})); }));
useEffect(() => { useEffect(() => {
@@ -35,8 +43,9 @@ export const FormerLayout = (props: PropsWithChildren) => {
} }
}, [getFormMethods, request]); }, [getFormMethods, request]);
if (disableHTMlForm) { return (
return ( <>
<FormerLayoutTop />
<ScrollAreaAutosize <ScrollAreaAutosize
offsetScrollbars offsetScrollbars
scrollbarSize={4} scrollbarSize={4}
@@ -44,13 +53,27 @@ export const FormerLayout = (props: PropsWithChildren) => {
{...scrollAreaProps} {...scrollAreaProps}
style={{ style={{
height: '100%', height: '100%',
maxHeight: '89vh',
padding: '0.25rem', padding: '0.25rem',
width: '100%', width: '100%',
...scrollAreaProps?.style, ...scrollAreaProps?.style,
}} }}
> >
{props.children} {disableHTMlForm ? (
<div x-data-request={request} key={`former_d${id}`}>
{props.children}
</div>
) : (
<form
key={`former_${id}`}
id={`former_f${id}`}
onReset={(e) => reset(e)}
onSubmit={(e) => save(e)}
x-data-request={request}
>
{props.children}
</form>
)}
<LoadingOverlay <LoadingOverlay
loaderProps={{ type: 'bars' }} loaderProps={{ type: 'bars' }}
overlayProps={{ overlayProps={{
@@ -60,34 +83,7 @@ export const FormerLayout = (props: PropsWithChildren) => {
visible={loading} visible={loading}
/> />
</ScrollAreaAutosize> </ScrollAreaAutosize>
); <FormerLayoutBottom />
} </>
return (
<ScrollAreaAutosize
offsetScrollbars
scrollbarSize={4}
type="auto"
{...scrollAreaProps}
style={{
height: '100%',
maxHeight: '89vh',
padding: '0.25rem',
width: '100%',
...scrollAreaProps?.style,
}}
>
<form onReset={(e) => reset(e)} onSubmit={(e) => save(e)}>
{props.children}
<LoadingOverlay
loaderProps={{ type: 'bars' }}
overlayProps={{
backgroundOpacity: 0.5,
}}
{...loadingOverlayProps}
visible={loading}
/>
</form>
</ScrollAreaAutosize>
); );
}; };

View File

@@ -0,0 +1,23 @@
import { useFormerStore } from './Former.store';
import { FormerButtonArea } from './FormerButtonArea';
export const FormerLayoutBottom = () => {
const { renderBottom, getState, opened, buttonOnTop } = useFormerStore((state) => ({
renderBottom: state.layout?.renderBottom,
buttonOnTop: state.layout?.buttonOnTop,
getState: state.getState,
opened: state.opened,
}));
if (renderBottom) {
return renderBottom(
<FormerButtonArea />,
opened,
getState('onClose'),
getState('onOpen'),
getState
);
}
return buttonOnTop ? <></> : <FormerButtonArea />;
};

View File

@@ -0,0 +1,22 @@
import { useFormerStore } from './Former.store';
import { FormerButtonArea } from './FormerButtonArea';
export const FormerLayoutTop = () => {
const { renderTop, getState, opened, buttonOnTop } = useFormerStore((state) => ({
renderTop: state.layout?.renderTop,
buttonOnTop: state.layout?.buttonOnTop,
getState: state.getState,
opened: state.opened,
}));
if (renderTop) {
return renderTop(
<FormerButtonArea />,
opened,
getState('onClose'),
getState('onOpen'),
getState
);
}
return buttonOnTop ? <FormerButtonArea /> : <></>;
};

View File

@@ -2,13 +2,20 @@ 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 { FormerRef } from '../Former.types'; import type { FormerProps, FormerRef } from '../Former.types';
import { Former } from '../Former'; import { Former } from '../Former';
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 [layout, setLayout] = useState({
buttonOnTop: false,
title: 'Custom Former Title',
buttonAreaGroupProps: { justify: 'center' },
} 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 });
console.log('formData', formData); console.log('formData', formData);
@@ -16,16 +23,28 @@ export const FormTest = () => {
const ref = useRef<FormerRef>(null); const ref = useRef<FormerRef>(null);
return ( return (
<Stack h="100%" mih="400px" w="90%"> <Stack h="100%" mih="400px" w="90%">
<Select <Group>
data={['insert', 'update', 'delete', 'select', 'view']} <Select
onChange={setRequest} data={['insert', 'update', 'delete', 'select', 'view']}
value={request} onChange={setRequest}
/> value={request}
<Switch />
checked={wrapped} <Switch
label="Wrapped in Drawer" checked={wrapped}
onChange={(event) => setWrapped(event.currentTarget.checked)} label="Wrapped in Drawer"
/> onChange={(event) => setWrapped(event.currentTarget.checked)}
/>
<Switch
checked={disableHTML}
label="Disable HTML Form"
onChange={(event) => setDisableHTML(event.currentTarget.checked)}
/>
<Switch
checked={layout?.buttonOnTop ?? false}
label="Button On Top"
onChange={(event) => setLayout({ ...layout, buttonOnTop: event.currentTarget.checked })}
/>
</Group>
<Button onClick={() => setOpen(true)}>Open Former Drawer</Button> <Button onClick={() => setOpen(true)}>Open Former Drawer</Button>
<Group> <Group>
<Button <Button
@@ -65,6 +84,7 @@ export const FormTest = () => {
}, 1000); }, 1000);
}); });
}} }}
disableHTMlForm={disableHTML}
onChange={setFormData} onChange={setFormData}
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
opened={open} opened={open}
@@ -73,9 +93,10 @@ export const FormTest = () => {
request={request as any} request={request as any}
useFormProps={{ criteriaMode: 'all', shouldUseNativeValidation: false }} useFormProps={{ criteriaMode: 'all', shouldUseNativeValidation: false }}
values={formData} values={formData}
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
@@ -107,10 +128,12 @@ export const FormTest = () => {
rules={{ required: 'Field is required' }} rules={{ required: 'Field is required' }}
/> />
</Stack> </Stack>
<Stack> {!disableHTML && (
<button type="submit">Submit</button> <Stack>
<button type="reset">Reset</button> <button type="submit">HTML Submit</button>
</Stack> <button type="reset">HTML Reset</button>
</Stack>
)}
</Stack> </Stack>
</Former> </Former>
</Stack> </Stack>

View File

@@ -1,12 +1,12 @@
- [ ] 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) - [ ] Predefined wrappers (Model,Dialog,notification,popover)
- [ ] Headerspec API - [ ] Headerspec API
- [ ] Relspec API - [ ] Relspec API
- [ ] SocketSpec API - [ ] SocketSpec API
- [ ] Layout Tool - [x] Layout Tool
- [ ] Header Section - [x] Header Section
- [ ] Button Section - [x] Button Section
- [ ] Footer Section - [x] Footer Section
- [ ] Different Loaded for saving vs loading - [ ] Different Loaded for saving vs loading
- [ ] Better Confirm Dialog - [ ] Better Confirm Dialog
- [ ] Reset Confirm Dialog - [ ] Reset Confirm Dialog