import { useEffect, useMemo, useRef } from 'react' import { useForm, type UseFormProps, type FieldValues, type FieldNamesMarkedBoolean, } from 'react-hook-form' import i18next from 'i18next' import { toast } from 'sonner' type SettingsFormOptions = UseFormProps & { onSubmit: (data: T, changedFields: Record) => Promise compareValues?: (a: unknown, b: unknown) => boolean } function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } function flattenValues( values: T, parentKey?: string, result: Record = {} ): Record { if (!isPlainObject(values)) { if (typeof parentKey === 'string') { result[parentKey] = values } return result } Object.entries(values).forEach(([key, value]) => { const nextKey = parentKey ? `${parentKey}.${key}` : key if (isPlainObject(value)) { flattenValues(value as FieldValues, nextKey, result) return } if (Array.isArray(value)) { result[nextKey] = value return } result[nextKey] = value }) return result } function collectDirtyValues( dirtyFields: Partial> | boolean, values: unknown, parentKey?: string, result: Record = {} ) { if (dirtyFields === true) { if (typeof parentKey === 'string') { result[parentKey] = values } return result } if (!dirtyFields || typeof dirtyFields !== 'object') { return result } Object.entries(dirtyFields).forEach(([key, value]) => { const nextKey = parentKey ? `${parentKey}.${key}` : key const source = values && (isPlainObject(values) || Array.isArray(values)) ? (values as Record)[key] : undefined if (value === true) { result[nextKey] = source return } if (value && typeof value === 'object') { collectDirtyValues( value as Partial>, source as FieldValues, nextKey, result ) } }) return result } function setDeepValue( target: Record, path: string, value: unknown ) { const segments = path.split('.') let current: Record = target segments.forEach((segment, index) => { const isLast = index === segments.length - 1 if (isLast) { current[segment] = value return } const next = current[segment] if (isPlainObject(next)) { current = next as Record return } current[segment] = {} current = current[segment] as Record }) } function expandDotPaths( values: T | undefined ): T | undefined { if (!values || !isPlainObject(values)) { return values } const result: Record = {} Object.entries(values).forEach(([key, value]) => { if (key.includes('.')) { setDeepValue(result, key, value) return } if (isPlainObject(value)) { result[key] = expandDotPaths(value as FieldValues) return } result[key] = value }) return result as T } /** * Unified hook for system settings forms * * Key features: * - Initializes form with defaultValues only on mount * - No automatic resets that could overwrite user input * - Tracks changed fields to minimize API calls * - Provides manual reset functionality * * @example * ```tsx * const { form, handleSubmit, handleReset } = useSettingsForm({ * resolver: zodResolver(schema), * defaultValues, * onSubmit: async (data, changed) => { * for (const [key, value] of Object.entries(changed)) { * await updateOption.mutateAsync({ key, value }) * } * } * }) * ``` */ export function useSettingsForm({ onSubmit, compareValues, defaultValues, ...formOptions }: SettingsFormOptions) { const expandedDefaults = useMemo( () => expandDotPaths(defaultValues), [defaultValues] ) const form = useForm({ ...formOptions, defaultValues: expandedDefaults }) const defaultValuesRef = useRef((expandedDefaults ?? ({} as T)) as T) const baselineRef = useRef>( flattenValues((expandedDefaults ?? ({} as T)) as T) ) /* eslint-disable react-hooks/refs */ const serializedDefaultsRef = useRef( JSON.stringify(baselineRef.current) ) /* eslint-enable react-hooks/refs */ useEffect(() => { if (!expandedDefaults) return const flattened = flattenValues(expandedDefaults as T) const serialized = JSON.stringify(flattened) if (serialized === serializedDefaultsRef.current) { return } baselineRef.current = flattened defaultValuesRef.current = expandedDefaults as T serializedDefaultsRef.current = serialized form.reset(expandedDefaults as T) }, [expandedDefaults, form]) const defaultCompare = (a: unknown, b: unknown): boolean => { if (a === b) return true if (Array.isArray(a) && Array.isArray(b)) { return JSON.stringify(a) === JSON.stringify(b) } if (typeof a !== typeof b) return false // Handle arrays // Handle objects (but not null) if (a && b && typeof a === 'object' && typeof b === 'object') { return JSON.stringify(a) === JSON.stringify(b) } return false } const compare = compareValues || defaultCompare const handleSubmit = async (data: T) => { const dirtyValues = collectDirtyValues(form.formState.dirtyFields, data) const changedEntries = Object.entries(dirtyValues).filter( ([key, value]) => !compare(value, baselineRef.current[key]) ) if (changedEntries.length === 0) { toast.info(i18next.t('No changes to save')) return } const changedFields = changedEntries.reduce>( (acc, [key, value]) => { acc[key] = value return acc }, {} ) await onSubmit(data, changedFields) const flattenedValues = flattenValues(data) baselineRef.current = flattenedValues defaultValuesRef.current = data serializedDefaultsRef.current = JSON.stringify(flattenedValues) form.reset(data) } const handleReset = () => { form.reset(defaultValuesRef.current) toast.success(i18next.t('Form reset to saved values')) } return { form, // eslint-disable-next-line react-hooks/refs handleSubmit: form.handleSubmit(handleSubmit), handleReset, isDirty: form.formState.isDirty, isSubmitting: form.formState.isSubmitting, } }