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'}
)}
- }
- size="sm"
- px="md"
- onClick={() => save()}
- miw={'8rem'}
+
+ Cannot save in view or select mode, or no changes made.
+ Try changing some values.
+
+ ) : (
+ Save the current record
+ )
+ }
>
- Save
-
+ }
+ size="sm"
+ px="md"
+ miw={'8rem'}
+ bg={request === 'delete' ? 'red' : undefined}
+ {...saveButtonProps}
+ disabled={disabledSave}
+ onClick={() => save()}
+ >
+ {saveButtonTitle || 'Save'}
+
+
);
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