Skip to content

Commit 6086750

Browse files
authored
Merge pull request #2600 from headlamp-k8s/map-query-state-fix
frontend: Rewrite useQueryParamsState logic and add unit tests
2 parents 1c0e695 + 7fc43ff commit 6086750

File tree

2 files changed

+126
-53
lines changed

2 files changed

+126
-53
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { act, renderHook } from '@testing-library/react';
2+
import { createMemoryHistory } from 'history';
3+
import { Router } from 'react-router-dom';
4+
import { useQueryParamsState } from './useQueryParamsState';
5+
6+
describe('useQueryParamsState', () => {
7+
it('should initialize with the initial state if no query param is present', () => {
8+
const history = createMemoryHistory();
9+
const wrapper = ({ children }: { children: React.ReactNode }) => (
10+
<Router history={history}>{children}</Router>
11+
);
12+
13+
const { result } = renderHook(() => useQueryParamsState('test', 'initial'), { wrapper });
14+
15+
expect(result.current[0]).toBe('initial');
16+
expect(history.length).toBe(1); // make sure it's replaced and not appended
17+
});
18+
19+
it('should initialize with the query param value if present', () => {
20+
const history = createMemoryHistory();
21+
history.replace('?test=value');
22+
23+
const wrapper = ({ children }: { children: React.ReactNode }) => (
24+
<Router history={history}>{children}</Router>
25+
);
26+
27+
const { result } = renderHook(() => useQueryParamsState('test', 'initial'), { wrapper });
28+
29+
expect(result.current[0]).toBe('value');
30+
expect(history.length).toBe(1);
31+
});
32+
33+
it('should update the query param value', () => {
34+
const history = createMemoryHistory();
35+
const wrapper = ({ children }: { children: React.ReactNode }) => (
36+
<Router history={history}>{children}</Router>
37+
);
38+
39+
const { result } = renderHook(() => useQueryParamsState<string>('test', 'initial'), {
40+
wrapper,
41+
});
42+
43+
act(() => {
44+
result.current[1]('new-value');
45+
});
46+
47+
expect(history.location.search).toBe('?test=new-value');
48+
expect(result.current[0]).toBe('new-value');
49+
expect(history.length).toBe(2);
50+
});
51+
52+
it('should remove the query param if the new value is undefined', () => {
53+
const history = createMemoryHistory();
54+
history.replace('?test=value');
55+
56+
const wrapper = ({ children }: { children: React.ReactNode }) => (
57+
<Router history={history}>{children}</Router>
58+
);
59+
60+
const { result } = renderHook(() => useQueryParamsState('test', 'initial'), { wrapper });
61+
62+
act(() => {
63+
result.current[1](undefined);
64+
});
65+
66+
expect(history.location.search).toBe('');
67+
expect(result.current[0]).toBeUndefined();
68+
expect(history.length).toBe(2);
69+
});
70+
71+
it('should replace the query param value if replace option is true', () => {
72+
const history = createMemoryHistory();
73+
const wrapper = ({ children }: { children: React.ReactNode }) => (
74+
<Router history={history}>{children}</Router>
75+
);
76+
77+
const { result } = renderHook(() => useQueryParamsState<string>('test', 'initial'), {
78+
wrapper,
79+
});
80+
81+
act(() => {
82+
result.current[1]('new-value', { replace: true });
83+
});
84+
85+
expect(history.location.search).toBe('?test=new-value');
86+
expect(result.current[0]).toBe('new-value');
87+
expect(history.length).toBe(1);
88+
});
89+
});
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
import { useCallback, useEffect, useState } from 'react';
1+
import { useCallback, useEffect, useMemo } from 'react';
22
import { useHistory, useLocation } from 'react-router';
33

4-
type UseQueryParamsStateReturnType<T> = [T | undefined, (newValue: T | undefined) => void];
4+
type UseQueryParamsStateReturnType<T> = [
5+
T | undefined,
6+
(newValue: T | undefined, params?: { replace?: boolean }) => void
7+
];
58

69
/**
710
* Custom hook to manage a state synchronized with a URL query parameter
@@ -18,64 +21,45 @@ export function useQueryParamsState<T extends string | undefined>(
1821
param: string,
1922
initialState: T
2023
): UseQueryParamsStateReturnType<T> {
21-
const location = useLocation();
24+
const { search } = useLocation();
2225
const history = useHistory();
2326

24-
// State for managing the value derived from the query parameter
25-
const [value, setValue] = useState<T | undefined>(() => {
26-
const { search } = location;
27-
const searchParams = new URLSearchParams(search);
28-
const paramValue = searchParams.get(param);
27+
const value = useMemo(() => {
28+
const params = new URLSearchParams(search);
29+
return (params.get(param) ?? undefined) as T | undefined;
30+
}, [search, param]);
2931

30-
return paramValue !== null ? (decodeURIComponent(paramValue) as T) : undefined;
31-
});
32-
33-
// Update the value from URL to state
34-
useEffect(() => {
35-
const searchParams = new URLSearchParams(location.search);
36-
const paramValue = searchParams.get(param);
37-
38-
if (paramValue !== null) {
39-
const decodedValue = decodeURIComponent(paramValue) as T;
40-
setValue(decodedValue);
41-
} else {
42-
setValue(undefined);
43-
}
44-
}, [location.search]);
45-
46-
// Set the value from state to URL
47-
useEffect(() => {
48-
const currentSearchParams = new URLSearchParams(location.search);
49-
50-
if (value && currentSearchParams.get(param) === encodeURIComponent(value)) return;
51-
52-
// Update the query parameter with the current state value
53-
if (value !== null && value !== '' && value !== undefined) {
54-
currentSearchParams.set(param, encodeURIComponent(value));
55-
} else {
56-
currentSearchParams.delete(param);
57-
}
58-
59-
// Update the URL with the modified search parameters
60-
const newUrl = [location.pathname, currentSearchParams.toString()].filter(Boolean).join('?');
61-
62-
history.push(newUrl);
63-
}, [param, value]);
64-
65-
// Initi state with initial state value
66-
useEffect(() => {
67-
setValue(initialState);
68-
}, []);
69-
70-
const handleSetValue = useCallback(
71-
(newValue: T | undefined) => {
32+
const setValue = useCallback(
33+
(newValue: T | undefined, params: { replace?: boolean } = {}) => {
7234
if (newValue !== undefined && typeof newValue !== 'string') {
7335
throw new Error("useQueryParamsState: Can't set a value to something that isn't a string");
7436
}
75-
setValue(newValue);
37+
38+
// Create new search params
39+
const newParams = new URLSearchParams(history.location.search);
40+
if (newValue === undefined) {
41+
newParams.delete(param);
42+
} else {
43+
newParams.set(param, newValue);
44+
}
45+
46+
// Apply new search params
47+
const newSearch = '?' + newParams;
48+
if (params.replace) {
49+
history.replace(newSearch);
50+
} else {
51+
history.push(newSearch);
52+
}
7653
},
77-
[setValue]
54+
[history.location.search, param]
7855
);
7956

80-
return [value, handleSetValue];
57+
// Apply initialState if any
58+
useEffect(() => {
59+
if (initialState && !value) {
60+
setValue(initialState, { replace: true });
61+
}
62+
}, [initialState]);
63+
64+
return [value, setValue];
8165
}

0 commit comments

Comments
 (0)