diff --git a/client/src/components/Manifest/Handler/Search/HandlerSearchForm.tsx b/client/src/components/Manifest/Handler/Search/HandlerSearchForm.tsx index fa91327f..ba11fd22 100644 --- a/client/src/components/Manifest/Handler/Search/HandlerSearchForm.tsx +++ b/client/src/components/Manifest/Handler/Search/HandlerSearchForm.tsx @@ -15,6 +15,7 @@ import { import { useSearchParams } from 'react-router-dom'; import Select from 'react-select'; import { useGetProfileQuery, useSearchRcrainfoSitesQuery, useSearchRcraSitesQuery } from 'store'; +import { useDebounce } from 'hooks'; interface Props { handleClose: () => void; @@ -37,6 +38,8 @@ export function HandlerSearchForm({ const { handleSubmit, control } = useForm(); const manifestForm = useFormContext(); const [inputValue, setInputValue] = useState(''); + const debouncedInputValue = useDebounce(inputValue, 500); + const [selectedHandler, setSelectedHandler] = useState(null); const { org } = useGetProfileQuery(undefined, { selectFromResult: ({ data }) => { @@ -47,9 +50,9 @@ export function HandlerSearchForm({ const { data } = useSearchRcraSitesQuery( { siteType: handlerType, - siteId: inputValue, + siteId: debouncedInputValue, }, - { skip } + { skip: skip || debouncedInputValue === '' } ); const { data: rcrainfoData, @@ -58,7 +61,7 @@ export function HandlerSearchForm({ } = useSearchRcrainfoSitesQuery( { siteType: handlerType, - siteId: inputValue, + siteId: debouncedInputValue, }, { skip: skip || !org?.rcrainfoIntegrated } ); @@ -96,9 +99,9 @@ export function HandlerSearchForm({ }; useEffect(() => { - const inputTooShort = inputValue.length < 5; + const inputTooShort = inputValue.length < 2; setSkip(inputTooShort); - }, [inputValue]); + }, [debouncedInputValue]); useEffect(() => { const knownSites = data && data.length > 0 ? data : []; diff --git a/client/src/hooks/index.ts b/client/src/hooks/index.ts index f502f570..223078c1 100644 --- a/client/src/hooks/index.ts +++ b/client/src/hooks/index.ts @@ -2,3 +2,4 @@ export { usePagination } from './usePagination/usePagination'; export { useProgressTracker } from './useProgressTracker/useProgressTracker'; export { useTitle } from './useTitle/useTitle'; export { useUserSiteIds } from './useUserSiteIds/useUserSiteIds'; +export { useDebounce } from './useDebounce/useDebounce'; diff --git a/client/src/hooks/useDebounce/useDebounce.spec.tsx b/client/src/hooks/useDebounce/useDebounce.spec.tsx new file mode 100644 index 00000000..0a57535f --- /dev/null +++ b/client/src/hooks/useDebounce/useDebounce.spec.tsx @@ -0,0 +1,63 @@ +import '@testing-library/jest-dom'; +import { renderHook, act } from '@testing-library/react'; +import { useDebounce } from './useDebounce'; +import { beforeAll, afterAll, afterEach, describe, expect, it, vi } from 'vitest'; + +describe('useDebounce hook', () => { + beforeAll(() => { + vi.useFakeTimers(); + }); + + afterAll(() => { + vi.clearAllTimers(); + }); + + it('should return the initial value immediately', () => { + const { result } = renderHook(() => useDebounce('initial', 500)); + expect(result.current).toBe('initial'); + }); + + it('should update the debounced value after the specified delay', () => { + const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), { + initialProps: { value: 'initial', delay: 500 }, + }); + + rerender({ value: 'updated', delay: 500 }); + + expect(result.current).toBe('initial'); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(result.current).toBe('updated'); + }); + + it('should reset the timer when value changes within the delay period', () => { + const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), { + initialProps: { value: 'initial', delay: 500 }, + }); + + rerender({ value: 'updated1', delay: 500 }); + act(() => { + vi.advanceTimersByTime(300); + }); + rerender({ value: 'updated2', delay: 500 }); + + act(() => { + vi.advanceTimersByTime(200); + }); + + expect(result.current).toBe('initial'); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(result.current).toBe('updated2'); + }); + + afterEach(() => { + vi.clearAllTimers(); + }); +}); diff --git a/client/src/hooks/useDebounce/useDebounce.tsx b/client/src/hooks/useDebounce/useDebounce.tsx new file mode 100644 index 00000000..2ccc1d1d --- /dev/null +++ b/client/src/hooks/useDebounce/useDebounce.tsx @@ -0,0 +1,17 @@ +import { useState, useEffect } from 'react'; + +export const useDebounce = (value: string, milliSeconds: number) => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, milliSeconds); + + return () => { + clearTimeout(handler); + }; + }, [value, milliSeconds]); + + return debouncedValue; +};