Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import CollectionSearch from './CollectionSearch/index';
import InlineCollectionCreator from './InlineCollectionCreator';
import path, { normalizePath } from 'utils/common/path';
import { isScratchCollection } from 'utils/collections';
import useDebounce from 'hooks/useDebounce';

const SEARCH_DEBOUNCE_MS = 300;
const isEmptyQuery = (value) => value === '';

const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });

Expand All @@ -21,6 +25,7 @@ const getSidebarEntryName = (entry) => {

const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismissCreate, onOpenAdvancedCreate }) => {
const [searchText, setSearchText] = useState('');
const debouncedSearchText = useDebounce(searchText, SEARCH_DEBOUNCE_MS, { skipDebounce: isEmptyQuery });
const { collections, collectionSortOrder } = useSelector((state) => state.collections);
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);

Expand Down Expand Up @@ -92,7 +97,7 @@ const Collections = ({ showSearch, isCreatingCollection, onCreateClick, onDismis
)}
{sidebarEntries.map((entry) => {
if (entry.kind === 'loaded') {
return <Collection searchText={searchText} collection={entry.collection} key={entry.key} />;
return <Collection searchText={debouncedSearchText} collection={entry.collection} key={entry.key} />;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please trim the input text

}
return <GitRemoteCollectionRow entry={entry.entry} key={entry.key} />;
})}
Expand Down
20 changes: 17 additions & 3 deletions packages/bruno-app/src/hooks/useDebounce/index.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,33 @@
import { useState, useEffect } from 'react';

function useDebounce(value, delay) {
/**
*
* @param {*} value
* @param {number} delay - Debounce delay in milliseconds.
* @param {object} [options]
* @param {(value: *) => boolean} [options.skipDebounce] - Values matching
* this are applied immediately instead of being debounced.
*/
function useDebounce(value, delay, { skipDebounce } = {}) {
const [debouncedValue, setDebouncedValue] = useState(value);
const isImmediate = typeof skipDebounce === 'function' && skipDebounce(value);

useEffect(() => {
if (isImmediate) {
setDebouncedValue(value);
return;
}

const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);

return () => {
clearTimeout(handler);
};
}, [value, delay]);
}, [value, delay, isImmediate]);

return debouncedValue;
return isImmediate ? value : debouncedValue;
}

export default useDebounce;
114 changes: 114 additions & 0 deletions packages/bruno-app/src/hooks/useDebounce/index.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { renderHook, act } from '@testing-library/react';
import useDebounce from './index';

const DELAY = 300;
const isEmpty = (value) => value === '';

const renderDebounce = (initialValue, options) =>
renderHook(({ value }) => useDebounce(value, DELAY, options), {
initialProps: { value: initialValue }
});

describe('useDebounce', () => {
beforeEach(() => {
jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
});

it('returns the initial value without waiting for the delay', () => {
const { result } = renderDebounce('abc');

expect(result.current).toBe('abc');
});

it('holds the previous value until the delay elapses', () => {
const { result, rerender } = renderDebounce('abc');

rerender({ value: 'abcd' });
expect(result.current).toBe('abc');

act(() => {
jest.advanceTimersByTime(DELAY - 1);
});
expect(result.current).toBe('abc');

act(() => {
jest.advanceTimersByTime(1);
});
expect(result.current).toBe('abcd');
});

it('restarts the delay on each change so only the last value lands', () => {
const { result, rerender } = renderDebounce('a');

rerender({ value: 'ab' });
act(() => {
jest.advanceTimersByTime(200);
});

rerender({ value: 'abc' });
act(() => {
jest.advanceTimersByTime(200);
});
expect(result.current).toBe('a');

act(() => {
jest.advanceTimersByTime(100);
});
expect(result.current).toBe('abc');
});

describe('skipDebounce', () => {
it('applies a matching value without waiting for the delay', () => {
const { result, rerender } = renderDebounce('abc', { skipDebounce: isEmpty });

act(() => {
jest.advanceTimersByTime(DELAY);
});
expect(result.current).toBe('abc');

rerender({ value: '' });
expect(result.current).toBe('');
});

it('does not resurface the cleared value when a new one is typed inside the delay', () => {
const { result, rerender } = renderDebounce('abc', { skipDebounce: isEmpty });

act(() => {
jest.advanceTimersByTime(DELAY);
});
expect(result.current).toBe('abc');

rerender({ value: '' });
expect(result.current).toBe('');

// Retyped well inside the delay: the pending clear is cancelled, so without
// the immediate path the hook would still be holding 'abc' here.
rerender({ value: 'x' });
act(() => {
jest.advanceTimersByTime(DELAY - 1);
});
expect(result.current).toBe('');

act(() => {
jest.advanceTimersByTime(1);
});
expect(result.current).toBe('x');
});

it('leaves non-matching values on the trailing edge', () => {
const { result, rerender } = renderDebounce('abc', { skipDebounce: isEmpty });

rerender({ value: 'abcd' });
expect(result.current).toBe('abc');

act(() => {
jest.advanceTimersByTime(DELAY);
});
expect(result.current).toBe('abcd');
});
});
});
Loading