@matthew.ngo/reform
Version:
A flexible and powerful React form management library with advanced validation, state observation, and multi-group support
298 lines (272 loc) • 8.1 kB
text/typescript
import { FieldValues, useFieldArray, UseFormReturn } from 'react-hook-form';
import { FormDataPath } from '../../types';
import { FormGroup } from './form-groups';
import { useCallback, useMemo } from 'react';
import { useMemoizedCallback } from '../../common/useMemoizedCallback';
import { ValidationResult } from '../validation';
import { ReformGroupHandler } from '../../typessss';
interface UseReformGroupsProps<T extends Record<string, any>> {
methods: UseFormReturn<{ groups: FormGroup<T>[] }>;
minGroups: number;
maxGroups: number;
defaultData: T;
onChange?: (groups: FormGroup<T>[]) => void;
}
// Memoize this function to prevent recreation on each render
const createPathString = (index: number, field: string): string => {
return `groups.${index}.data.${field}`;
};
export const useReformGroups = <T extends Record<string, any> & FieldValues>({
methods,
minGroups,
maxGroups,
defaultData,
onChange,
}: UseReformGroupsProps<T>): ReformGroupHandler<T> => {
// Memoize defaultData to prevent unnecessary re-renders
const memoizedDefaultData = useMemo(() => defaultData, [
// Stringify defaultData to compare by value instead of reference
JSON.stringify(defaultData),
]);
const { fields, append, remove, move, update, replace } = useFieldArray({
control: methods.control,
name: 'groups',
});
// Memoize this function to prevent recreation on each render
const createNewGroup = useCallback(
(data: T = memoizedDefaultData): FormGroup<T> => ({
id: Math.random()
.toString(36)
.substring(2, 9),
data: { ...data },
}),
[memoizedDefaultData]
);
const addGroup = useMemoizedCallback(async (): Promise<ValidationResult> => {
if (fields.length >= maxGroups) {
return {
isValid: false,
message: `Maximum ${maxGroups} groups allowed`,
};
}
append(createNewGroup());
const isValid = await methods.trigger();
onChange?.(methods.getValues().groups);
return { isValid };
}, [
fields.length,
maxGroups,
append,
createNewGroup,
methods.trigger,
methods.getValues,
onChange,
]);
const removeGroup = useMemoizedCallback(
async (index: number): Promise<ValidationResult> => {
if (fields.length <= minGroups) {
return {
isValid: false,
message: `Minimum ${minGroups} groups required`,
};
}
remove(index);
const isValid = await methods.trigger();
onChange?.(methods.getValues().groups);
return { isValid };
},
[
fields.length,
minGroups,
remove,
methods.trigger,
methods.getValues,
onChange,
]
);
const moveGroup = useMemoizedCallback(
async (from: number, to: number): Promise<ValidationResult> => {
if (from < 0 || from >= fields.length || to < 0 || to >= fields.length) {
return { isValid: false, message: 'Invalid group indices' };
}
move(from, to);
const isValid = await methods.trigger();
onChange?.(methods.getValues().groups);
return { isValid };
},
[fields.length, move, methods.trigger, methods.getValues, onChange]
);
const duplicateGroup = useMemoizedCallback(
async (index: number): Promise<ValidationResult> => {
if (fields.length >= maxGroups) {
return {
isValid: false,
message: `Maximum ${maxGroups} groups allowed`,
};
}
const groupToDuplicate = methods.getValues(`groups.${index}`);
if (!groupToDuplicate) {
return { isValid: false, message: 'Group not found' };
}
append(createNewGroup(groupToDuplicate.data));
const isValid = await methods.trigger();
onChange?.(methods.getValues().groups);
return { isValid };
},
[
fields.length,
maxGroups,
methods.getValues,
append,
createNewGroup,
methods.trigger,
onChange,
]
);
// Batch operations
const batchUpdate = useMemoizedCallback(
async (
indices: number[],
updater: (data: T) => Partial<T>
): Promise<ValidationResult> => {
// Validate indices
const invalidIndices = indices.filter(
index => index < 0 || index >= fields.length
);
if (invalidIndices.length > 0) {
return {
isValid: false,
message: `Invalid indices: ${invalidIndices.join(', ')}`,
};
}
// Update each group
for (const index of indices) {
const group = methods.getValues(`groups.${index}`);
if (group) {
const updatedData = {
...group.data,
...updater(group.data),
};
update(index, {
...group,
data: updatedData,
});
}
}
// Validate and notify
const isValid = await methods.trigger();
onChange?.(methods.getValues().groups);
return { isValid };
},
[fields.length, methods.getValues, methods.trigger, update, onChange]
);
const batchRemove = useMemoizedCallback(
async (indices: number[]): Promise<ValidationResult> => {
// Sort indices in descending order to avoid index shifting issues
const sortedIndices = [...indices].sort((a, b) => b - a);
// Check if removing would violate minimum groups constraint
if (fields.length - sortedIndices.length < minGroups) {
return {
isValid: false,
message: `Cannot remove ${sortedIndices.length} groups. Minimum ${minGroups} groups required.`,
};
}
// Validate indices
const invalidIndices = sortedIndices.filter(
index => index < 0 || index >= fields.length
);
if (invalidIndices.length > 0) {
return {
isValid: false,
message: `Invalid indices: ${invalidIndices.join(', ')}`,
};
}
// Remove groups one by one
for (const index of sortedIndices) {
remove(index);
}
// Validate and notify
const isValid = await methods.trigger();
onChange?.(methods.getValues().groups);
return { isValid };
},
[
fields.length,
minGroups,
remove,
methods.trigger,
methods.getValues,
onChange,
]
);
/**
* Get all form groups
*/
const getGroups = useCallback((): FormGroup<T>[] => {
return methods.getValues().groups || [];
}, [methods]);
/**
* Set all form groups
*
* @param groups - New groups to set
* @returns Promise with validation result
*/
const setGroups = useCallback(
async (groups: FormGroup<T>[]): Promise<ValidationResult> => {
// Validate groups count
if (groups.length < minGroups) {
return {
isValid: false,
message: `Minimum ${minGroups} groups required`,
};
}
if (groups.length > maxGroups) {
return {
isValid: false,
message: `Maximum ${maxGroups} groups allowed`,
};
}
// Replace all groups
replace(groups);
// Validate and notify
const isValid = await methods.trigger();
onChange?.(methods.getValues().groups);
return { isValid };
},
[methods, minGroups, maxGroups, replace, onChange]
);
return useMemo(
() => ({
groups: fields as unknown as FormGroup<T>[], // Type assertion to fix compatibility issue
canAddGroup: fields.length < maxGroups,
canRemoveGroup: fields.length > minGroups,
register: <K extends string>(index: number, field: K, options?: any) =>
methods.register(
createPathString(index, field) as FormDataPath<T>,
options
),
getGroups,
setGroups,
addGroup,
removeGroup,
moveGroup,
duplicateGroup,
batchUpdate,
batchRemove,
}),
[
fields,
maxGroups,
minGroups,
methods.register,
getGroups,
setGroups,
addGroup,
removeGroup,
moveGroup,
duplicateGroup,
batchUpdate,
batchRemove,
]
);
};