From 6d7888e56aa164d81e095336b9c1d26200a481ce Mon Sep 17 00:00:00 2001 From: Marco polo Date: Tue, 20 Aug 2024 10:13:30 -0400 Subject: [PATCH] Move `useQueryPersistedState` to ui-core (#23743) ## 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 --- .../useQueryPersistedFilterState.test.tsx | 105 ++++++++++++++++++ .../hooks/useQueryPersistedFilterState.tsx | 65 +++++++++++ 2 files changed, 170 insertions(+) create mode 100644 js_modules/dagster-ui/packages/ui-core/src/hooks/__tests__/useQueryPersistedFilterState.test.tsx create mode 100644 js_modules/dagster-ui/packages/ui-core/src/hooks/useQueryPersistedFilterState.tsx diff --git a/js_modules/dagster-ui/packages/ui-core/src/hooks/__tests__/useQueryPersistedFilterState.test.tsx b/js_modules/dagster-ui/packages/ui-core/src/hooks/__tests__/useQueryPersistedFilterState.test.tsx new file mode 100644 index 0000000000000..6186cd09daffb --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-core/src/hooks/__tests__/useQueryPersistedFilterState.test.tsx @@ -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['initialEntries']; +} = {}) => { + return ({children}: {children: React.ReactNode}) => ( + {children} + ); +}; + +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); + }); +}); diff --git a/js_modules/dagster-ui/packages/ui-core/src/hooks/useQueryPersistedFilterState.tsx b/js_modules/dagster-ui/packages/ui-core/src/hooks/useQueryPersistedFilterState.tsx new file mode 100644 index 0000000000000..3d8db8e88008a --- /dev/null +++ b/js_modules/dagster-ui/packages/ui-core/src/hooks/useQueryPersistedFilterState.tsx @@ -0,0 +1,65 @@ +import {SetStateAction, useMemo} from 'react'; + +import {useQueryPersistedState} from './useQueryPersistedState'; + +type SetterType, K extends keyof T & string> = { + [P in K as `set${Capitalize

}`]: (value: SetStateAction) => void; +}; + +export const useQueryPersistedFilterState = >( + filterFields: readonly (keyof T)[], +): { + state: T; + setState: React.Dispatch>; + setters: SetterType>; +} => { + 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) => { + return filterFields.reduce((acc, field) => { + acc[field] = qs[field as string] ? JSON.parse(qs[field]!) : []; + return acc; + }, {} as T); + }; + + const [state, setState] = useQueryPersistedState({ + encode, + decode, + }); + + const createSetters = () => { + const setters = {} as SetterType>; + + filterFields.forEach((field) => { + const fieldAsString = field as keyof T & string; + const key = `set${ + fieldAsString.charAt(0).toUpperCase() + fieldAsString.slice(1) + }` as keyof SetterType>; + + 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, + }; +};