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 type { FormerProps, FormerState } from './Former.types';
import { newUUID } from '@warkypublic/artemis-kit';
const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore<
FormerState<any> & Partial<FormerProps<any>>,
@@ -168,18 +169,20 @@ const { Provider: FormerProvider, useStore: useFormerStore } = createSyncStore<
},
values: undefined,
}),
({ onConfirmDelete, primeData, request, values }) => {
({ onConfirmDelete, primeData, request, values, id }) => {
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 },
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';
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> {
id?: string;
afterGet?: (data: T) => Promise<T> | void;
afterSave?: (data: T) => Promise<void> | void;
apiKeyField?: string;
@@ -25,13 +39,17 @@ export interface FormerProps<T extends FieldValues = any> {
request: RequestType;
useFormProps?: UseFormProps<T>;
values?: T;
wrapper?: (
children: React.ReactNode,
opened: boolean | undefined,
onClose: ((data?: T) => void) | undefined,
onOpen: ((data?: T) => void) | undefined,
getState: <K extends keyof FormStateAndProps<T>>(key: K) => FormStateAndProps<T>[K]
) => React.ReactNode;
wrapper?: FormerSectionRender<T>;
layout?: {
renderTop?: FormerSectionRender<T>;
renderBottom?: FormerSectionRender<T>;
saveButtonProps?: ButtonProps;
closeButtonProps?: ButtonProps;
buttonOnTop?: boolean;
buttonAreaGroupProps?: GroupProps;
title?: string;
};
}
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 { useFormerStore } from './Former.store';
import { FormerLayoutBottom } from './FormerLayoutBottom';
import { FormerLayoutTop } from './FormerLayoutTop';
export const FormerLayout = (props: PropsWithChildren) => {
const {
@@ -14,6 +16,9 @@ export const FormerLayout = (props: PropsWithChildren) => {
reset,
save,
scrollAreaProps,
id,
layout,
getState,
} = useFormerStore((state) => ({
disableHTMlForm: state.disableHTMlForm,
getFormMethods: state.getFormMethods,
@@ -24,6 +29,9 @@ export const FormerLayout = (props: PropsWithChildren) => {
reset: state.reset,
save: state.save,
scrollAreaProps: state.scrollAreaProps,
id: state.id,
layout: state.layout,
getState: state.getState,
}));
useEffect(() => {
@@ -35,8 +43,9 @@ export const FormerLayout = (props: PropsWithChildren) => {
}
}, [getFormMethods, request]);
if (disableHTMlForm) {
return (
return (
<>
<FormerLayoutTop />
<ScrollAreaAutosize
offsetScrollbars
scrollbarSize={4}
@@ -44,13 +53,27 @@ export const FormerLayout = (props: PropsWithChildren) => {
{...scrollAreaProps}
style={{
height: '100%',
maxHeight: '89vh',
padding: '0.25rem',
width: '100%',
...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
loaderProps={{ type: 'bars' }}
overlayProps={{
@@ -60,34 +83,7 @@ export const FormerLayout = (props: PropsWithChildren) => {
visible={loading}
/>
</ScrollAreaAutosize>
);
}
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>
<FormerLayoutBottom />
</>
);
};

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 { Controller } from 'react-hook-form';
import type { FormerRef } from '../Former.types';
import type { FormerProps, FormerRef } from '../Former.types';
import { Former } from '../Former';
export const FormTest = () => {
const [request, setRequest] = useState<null | string>('insert');
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 [formData, setFormData] = useState({ a: 99 });
console.log('formData', formData);
@@ -16,16 +23,28 @@ export const FormTest = () => {
const ref = useRef<FormerRef>(null);
return (
<Stack h="100%" mih="400px" w="90%">
<Select
data={['insert', 'update', 'delete', 'select', 'view']}
onChange={setRequest}
value={request}
/>
<Switch
checked={wrapped}
label="Wrapped in Drawer"
onChange={(event) => setWrapped(event.currentTarget.checked)}
/>
<Group>
<Select
data={['insert', 'update', 'delete', 'select', 'view']}
onChange={setRequest}
value={request}
/>
<Switch
checked={wrapped}
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>
<Group>
<Button
@@ -65,6 +84,7 @@ export const FormTest = () => {
}, 1000);
});
}}
disableHTMlForm={disableHTML}
onChange={setFormData}
onClose={() => setOpen(false)}
opened={open}
@@ -73,9 +93,10 @@ export const FormTest = () => {
request={request as any}
useFormProps={{ criteriaMode: 'all', shouldUseNativeValidation: false }}
values={formData}
layout={layout}
wrapper={
wrapped
? (children, opened, onClose, onOpen, getState) => {
? (children, opened, onClose, _onOpen, getState) => {
const values = getState('values');
return (
<Drawer
@@ -107,10 +128,12 @@ export const FormTest = () => {
rules={{ required: 'Field is required' }}
/>
</Stack>
<Stack>
<button type="submit">Submit</button>
<button type="reset">Reset</button>
</Stack>
{!disableHTML && (
<Stack>
<button type="submit">HTML Submit</button>
<button type="reset">HTML Reset</button>
</Stack>
)}
</Stack>
</Former>
</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)
- [ ] Headerspec API
- [ ] Relspec API
- [ ] SocketSpec API
- [ ] Layout Tool
- [ ] Header Section
- [ ] Button Section
- [ ] Footer Section
- [x] Layout Tool
- [x] Header Section
- [x] Button Section
- [x] Footer Section
- [ ] Different Loaded for saving vs loading
- [ ] Better Confirm Dialog
- [ ] Reset Confirm Dialog