Skip to content

Commit

Permalink
Move useQueryPersistedState to ui-core (#23743)
Browse files Browse the repository at this point in the history
## Summary & Motivation
Moving this hook from cloud to OSS so I can use it here.

This hook is designed to manage filter states that are persisted via
query strings in the URL.
It provides a state object, a setState function, and dynamically
generated setter functions for each filter field.
The hook leverages useQueryPersistedState to persist and retrieve state
from the URL.

## How I Tested These Changes

jest
  • Loading branch information
salazarm authored Aug 20, 2024
1 parent 98b39b8 commit 6d7888e
Show file tree
Hide file tree
Showing 2 changed files with 170 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import {act, renderHook} from '@testing-library/react-hooks';
import React from 'react';
import {MemoryRouter} from 'react-router';

import {useQueryPersistedFilterState} from '../useQueryPersistedFilterState';

const wrapper = ({
initialEntries,
}: {
initialEntries?: React.ComponentProps<typeof MemoryRouter>['initialEntries'];
} = {}) => {
return ({children}: {children: React.ReactNode}) => (
<MemoryRouter initialEntries={initialEntries}>{children}</MemoryRouter>
);
};

describe('useQueryPersistedFilterState', () => {
afterEach(() => {
jest.clearAllMocks();
});

it('should initialize with decoded state from query string', () => {
const {result} = renderHook(
() => useQueryPersistedFilterState<{filterA: string[]; filterB: string[]}>(['filterA', 'filterB']),
{
wrapper: wrapper({
initialEntries: [
`/?filterA=${encodeURIComponent(
JSON.stringify(['value1']),
)}&filterB=${encodeURIComponent(JSON.stringify(['value2']))}`,
],
}),
},
);

expect(result.current.state).toEqual({
filterA: ['value1'],
filterB: ['value2'],
});
});

it('should encode the state correctly when setting a value', () => {
const {result} = renderHook(
() => useQueryPersistedFilterState<{filterA: string[]; filterB: string[]}>(['filterA', 'filterB']),
{wrapper: wrapper()},
);

act(() => {
result.current.setters.setFilterA(['newValue']);
});

expect(result.current.state.filterA).toEqual(['newValue']);
});

it('should create setters dynamically for each filter field', () => {
const {result} = renderHook(
() => useQueryPersistedFilterState<{filterA: string[]; filterB: string[]}>(['filterA', 'filterB']),
{wrapper: wrapper()},
);

expect(result.current.setters).toHaveProperty('setFilterA');
expect(result.current.setters).toHaveProperty('setFilterB');

act(() => {
result.current.setters.setFilterA(['valueA']);
result.current.setters.setFilterB(['valueB']);
});

expect(result.current.state.filterA).toEqual(['valueA']);
expect(result.current.state.filterB).toEqual(['valueB']);
});

it('should handle undefined or empty values correctly', () => {
const {result} = renderHook(
() =>
useQueryPersistedFilterState<{filterA: string[] | undefined; filterB: string[] | undefined}>([
'filterA',
'filterB',
]),
{wrapper: wrapper()},
);

act(() => {
result.current.setters.setFilterA([]);
result.current.setters.setFilterB(undefined);
});

expect(result.current.state.filterA).toEqual([]);
expect(result.current.state.filterB).toEqual([]);
});

it('should return memoized setters', () => {
const FIELDS = ['filterA', 'filterB'] as const;
const {result, rerender} = renderHook(
() => useQueryPersistedFilterState<{filterA: string[]; filterB: string[]}>(FIELDS),
{wrapper: wrapper()},
);

const initialSetters = result.current.setters;

rerender();

expect(result.current.setters).toBe(initialSetters);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {SetStateAction, useMemo} from 'react';

import {useQueryPersistedState} from './useQueryPersistedState';

type SetterType<T extends Record<string, any>, K extends keyof T & string> = {
[P in K as `set${Capitalize<P>}`]: (value: SetStateAction<T[P]>) => void;
};

export const useQueryPersistedFilterState = <T extends Record<string, any | undefined>>(
filterFields: readonly (keyof T)[],
): {
state: T;
setState: React.Dispatch<React.SetStateAction<T>>;
setters: SetterType<T, Extract<keyof T, string>>;
} => {
const encode = (filters: T) => {
return filterFields.reduce((acc, field) => {
const value = filters[field];
acc[field] = value?.length
? (JSON.stringify(value) as T[keyof T])
: (undefined as T[keyof T]);
return acc;
}, {} as T);
};

const decode = (qs: Record<string, string | undefined>) => {
return filterFields.reduce((acc, field) => {
acc[field] = qs[field as string] ? JSON.parse(qs[field]!) : [];
return acc;
}, {} as T);
};

const [state, setState] = useQueryPersistedState<T>({
encode,
decode,
});

const createSetters = () => {
const setters = {} as SetterType<T, Extract<keyof T, string>>;

filterFields.forEach((field) => {
const fieldAsString = field as keyof T & string;
const key = `set${
fieldAsString.charAt(0).toUpperCase() + fieldAsString.slice(1)
}` as keyof SetterType<T, Extract<keyof T, string>>;

setters[key] = ((value: any) => {
setState((prevState: T) => ({
...prevState,
[fieldAsString]: value instanceof Function ? value(prevState[fieldAsString]) : value,
}));
}) as any;
});

return setters;
};

const setters = useMemo(createSetters, [filterFields, setState]);

return {
state,
setState,
setters,
};
};

1 comment on commit 6d7888e

@github-actions
Copy link

Choose a reason for hiding this comment

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

Deploy preview for dagit-core-storybook ready!

✅ Preview
https://dagit-core-storybook-q8vj09bws-elementl.vercel.app

Built with commit 6d7888e.
This pull request is being automatically deployed with vercel-action

Please sign in to comment.