@promoboxx/use-filter 1.11.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/.github/workflows/main.yml +38 -0
  2. package/.vscode/settings.json +3 -0
  3. package/CHANGELOG.md +185 -0
  4. package/Makefile +25 -0
  5. package/eslint.config.js +30 -0
  6. package/mise.toml +3 -0
  7. package/package.json +33 -43
  8. package/prettier.config.js +3 -0
  9. package/src/lib/buildDefaultFilterInfo.ts +36 -0
  10. package/src/lib/getOffsetFromPage.ts +5 -0
  11. package/src/lib/getPageFromOffset.ts +5 -0
  12. package/src/lib/shallowEqual.test.ts +71 -0
  13. package/src/lib/shallowEqual.ts +26 -0
  14. package/src/store/index.ts +30 -0
  15. package/src/store/localStorageStore.ts +36 -0
  16. package/src/store/memoryStore.ts +27 -0
  17. package/src/store/reduxHelpers/createActions.test.ts +32 -0
  18. package/src/store/reduxHelpers/createActions.ts +56 -0
  19. package/src/store/reduxHelpers/createReducer.test.ts +65 -0
  20. package/src/store/reduxHelpers/createReducer.ts +47 -0
  21. package/src/store/reduxStore.ts +78 -0
  22. package/src/store/urlParamStore.test.ts +131 -0
  23. package/src/store/urlParamStore.ts +85 -0
  24. package/src/useFilter.test.tsx +822 -0
  25. package/src/useFilter.ts +524 -0
  26. package/src/useSimpleFilter.test.tsx +676 -0
  27. package/src/useSimpleFilter.ts +397 -0
  28. package/src/vitest-env.d.ts +1 -0
  29. package/tsconfig.json +76 -0
  30. package/tsdown.config.ts +30 -0
  31. package/vite.config.ts +9 -0
  32. package/dist/lib/buildDefaultFilterInfo.d.ts +0 -3
  33. package/dist/lib/buildDefaultFilterInfo.js +0 -35
  34. package/dist/lib/getOffsetFromPage.d.ts +0 -2
  35. package/dist/lib/getOffsetFromPage.js +0 -6
  36. package/dist/lib/getPageFromOffset.d.ts +0 -2
  37. package/dist/lib/getPageFromOffset.js +0 -6
  38. package/dist/lib/shallowEqual.d.ts +0 -2
  39. package/dist/lib/shallowEqual.js +0 -23
  40. package/dist/store/index.d.ts +0 -10
  41. package/dist/store/index.js +0 -16
  42. package/dist/store/localStorageStore.d.ts +0 -3
  43. package/dist/store/localStorageStore.js +0 -31
  44. package/dist/store/memoryStore.d.ts +0 -3
  45. package/dist/store/memoryStore.js +0 -23
  46. package/dist/store/reduxHelpers/createActions.d.ts +0 -16
  47. package/dist/store/reduxHelpers/createActions.js +0 -27
  48. package/dist/store/reduxHelpers/createReducer.d.ts +0 -8
  49. package/dist/store/reduxHelpers/createReducer.js +0 -26
  50. package/dist/store/reduxStore.d.ts +0 -15
  51. package/dist/store/reduxStore.js +0 -67
  52. package/dist/store/urlParamStore.d.ts +0 -2
  53. package/dist/store/urlParamStore.js +0 -86
  54. package/dist/useFilter.d.ts +0 -103
  55. package/dist/useFilter.js +0 -254
  56. package/dist/useSimpleFilter.d.ts +0 -86
  57. package/dist/useSimpleFilter.js +0 -173
@@ -0,0 +1,56 @@
1
+ interface CreateActions {
2
+ <Namespace extends string, PayloadCreatorMap extends ReduxPayloadCreatorMap>(
3
+ namespace: Namespace,
4
+ actions: PayloadCreatorMap,
5
+ ): ReduxActionCreatorMap<Namespace, PayloadCreatorMap>
6
+ }
7
+
8
+ type ReduxPayloadCreatorMap = Record<string, (...args: any[]) => any>
9
+
10
+ export type ReduxActionCreatorMap<
11
+ Namespace extends string,
12
+ PayloadCreatorMap extends ReduxPayloadCreatorMap,
13
+ > = {
14
+ [Type in keyof PayloadCreatorMap]: Type extends string
15
+ ? ReduxActionCreator<Namespace, Type, PayloadCreatorMap[Type]>
16
+ : undefined
17
+ }
18
+
19
+ // Provide defaults for types in here cause it's used a few places and subbing
20
+ // in the defaults is long and repetitive.
21
+ export interface ReduxActionCreator<
22
+ Namespace extends string = string,
23
+ Type extends string = string,
24
+ Fn extends (...args: any[]) => any = (...args: any[]) => any,
25
+ > {
26
+ (...args: Parameters<Fn>): {
27
+ type: `${Namespace}/${Type}`
28
+ payload: ReturnType<Fn>
29
+ }
30
+ actionType: `${Namespace}/${Type}`
31
+ }
32
+
33
+ const createActions: CreateActions = (
34
+ namespace: string,
35
+ payloadCreatorMap: ReduxPayloadCreatorMap,
36
+ ) => {
37
+ const actionCreators: Record<string, ReduxActionCreator> = {}
38
+
39
+ for (const key in payloadCreatorMap) {
40
+ actionCreators[key] = ((...args: any[]) => ({
41
+ type: `${namespace}/${key}`,
42
+ payload: payloadCreatorMap[key](...args),
43
+
44
+ // Need to cast here because there's no way to assign a function + extra
45
+ // attributes in one go.
46
+ })) as any
47
+
48
+ actionCreators[key].actionType = `${namespace}/${key}`
49
+ }
50
+
51
+ // TODO There's probably a better type to use here, but it's not super
52
+ // important since `CreateActions` already specifies one.
53
+ return actionCreators as any
54
+ }
55
+
56
+ export default createActions
@@ -0,0 +1,65 @@
1
+ import createActions from './createActions'
2
+ import createReducer from './createReducer'
3
+
4
+ const initialState = {
5
+ number: 0,
6
+ string: '',
7
+ foo: '',
8
+ }
9
+
10
+ describe('createReducer', () => {
11
+ it('uses a builder pattern', () => {
12
+ const actions = createActions('test', {
13
+ action1: () => undefined,
14
+ action2: () => 'asdf',
15
+ action3: (arg1: number, arg2: string) => ({ arg1, arg2 }),
16
+ })
17
+
18
+ const reducer = createReducer(initialState, (builder) => {
19
+ builder
20
+ .addHandler(actions.action2, (state, action) => ({
21
+ ...state,
22
+ string: action.payload,
23
+ }))
24
+ .addHandler(actions.action3, (state, action) => ({
25
+ ...state,
26
+ number: action.payload.arg1,
27
+ string: action.payload.arg2,
28
+ }))
29
+ .addCase('foo', (state, action: ReturnType<typeof actions.action1>) => {
30
+ return {
31
+ ...state,
32
+ foo: 'updated',
33
+ }
34
+ })
35
+ })
36
+
37
+ let state = reducer(undefined, { type: '' })
38
+ expect(state).toEqual({
39
+ number: 0,
40
+ string: '',
41
+ foo: '',
42
+ })
43
+
44
+ state = reducer(state, actions.action2())
45
+ expect(state).toEqual({
46
+ number: 0,
47
+ string: 'asdf',
48
+ foo: '',
49
+ })
50
+
51
+ state = reducer(state, actions.action3(42, 'foo'))
52
+ expect(state).toEqual({
53
+ number: 42,
54
+ string: 'foo',
55
+ foo: '',
56
+ })
57
+
58
+ state = reducer(state, { type: 'foo' })
59
+ expect(state).toEqual({
60
+ number: 42,
61
+ string: 'foo',
62
+ foo: 'updated',
63
+ })
64
+ })
65
+ })
@@ -0,0 +1,47 @@
1
+ import type { Action } from 'redux'
2
+
3
+ import type { ReduxActionCreator } from './createActions'
4
+
5
+ interface ReduxReducerBuilder<State> {
6
+ addHandler: <ActionCreator extends ReduxActionCreator>(
7
+ actionCreator: ActionCreator,
8
+ handler: (state: State, action: ReturnType<ActionCreator>) => State,
9
+ ) => ReduxReducerBuilder<State>
10
+
11
+ addCase: <HandlerAction extends Action>(
12
+ actionType: string,
13
+ handler: (state: State, action: HandlerAction) => State,
14
+ ) => ReduxReducerBuilder<State>
15
+ }
16
+
17
+ function createReducer<State>(
18
+ initialState: State,
19
+ builderFn: (builder: ReduxReducerBuilder<State>) => void,
20
+ ) {
21
+ const handlers: Record<string, (state: State, action: Action) => State> = {}
22
+
23
+ const builder: ReduxReducerBuilder<State> = {
24
+ addHandler: (actionCreator, handler) => {
25
+ // TODO There's probably a better type to use here.
26
+ handlers[actionCreator.actionType] = handler as any
27
+ return builder
28
+ },
29
+ addCase: (actionType, handler) => {
30
+ // TODO There's probably a better type to use here.
31
+ handlers[actionType] = handler as any
32
+ return builder
33
+ },
34
+ }
35
+
36
+ builderFn(builder)
37
+
38
+ return (state = initialState, action: Action) => {
39
+ if (handlers[action.type]) {
40
+ return handlers[action.type](state, action)
41
+ }
42
+
43
+ return state
44
+ }
45
+ }
46
+
47
+ export default createReducer
@@ -0,0 +1,78 @@
1
+ import type { Store } from 'redux'
2
+
3
+ import type { FilterInfo } from '../useFilter'
4
+
5
+ import type { FilterStore } from '.'
6
+ import createActions from './reduxHelpers/createActions'
7
+ import createReducer from './reduxHelpers/createReducer'
8
+
9
+ interface CreateReduxStoreConfig {
10
+ store: Store<{ useFilter: UseFilterReduxState }>
11
+ }
12
+
13
+ export function createReduxStore(config: CreateReduxStoreConfig) {
14
+ const reduxStoreStore: FilterStore = {
15
+ getFilter: (namespace) => {
16
+ return config.store.getState().useFilter.filters[namespace]
17
+ },
18
+ saveFilter: (namespace, filter) => {
19
+ config.store.dispatch(actions.saveFilter(namespace, filter))
20
+ },
21
+ getData: (namespace) => {
22
+ return config.store.getState().useFilter.data[namespace]
23
+ },
24
+ saveData: (namespace, data) => {
25
+ config.store.dispatch(actions.saveData(namespace, data))
26
+ },
27
+ clear: () => {
28
+ config.store.dispatch(actions.clear())
29
+ },
30
+ }
31
+
32
+ return reduxStoreStore
33
+ }
34
+
35
+ const actions = createActions('useFilter', {
36
+ saveData: (namespace: string, data: any) => ({
37
+ namespace,
38
+ data,
39
+ }),
40
+ saveFilter: (namespace: string, filter: FilterInfo<any>) => ({
41
+ namespace,
42
+ filter,
43
+ }),
44
+ clear: () => undefined,
45
+ })
46
+
47
+ interface UseFilterReduxState {
48
+ data: Record<string, any>
49
+ filters: Record<string, FilterInfo<any>>
50
+ }
51
+
52
+ const initialState: UseFilterReduxState = {
53
+ data: {},
54
+ filters: {},
55
+ }
56
+
57
+ export const useFilterReduxReducer = createReducer(initialState, (builder) => {
58
+ builder
59
+ .addHandler(actions.saveFilter, (state, action) => ({
60
+ ...state,
61
+ filters: {
62
+ ...state.filters,
63
+ [action.payload.namespace]: action.payload.filter,
64
+ },
65
+ }))
66
+ .addHandler(actions.saveData, (state, action) => ({
67
+ ...state,
68
+ data: {
69
+ ...state.data,
70
+ [action.payload.namespace]: action.payload.data,
71
+ },
72
+ }))
73
+ .addHandler(actions.clear, (state) => ({
74
+ ...state,
75
+ filters: {},
76
+ data: {},
77
+ }))
78
+ })
@@ -0,0 +1,131 @@
1
+ import qs from 'qs'
2
+
3
+ import buildDefaultFilterInfo from '../lib/buildDefaultFilterInfo'
4
+
5
+ import { createUrlParamStore, replaceQueryParams } from './urlParamStore'
6
+
7
+ const FILTER_NAME = 'test'
8
+
9
+ describe('urlParamStore', () => {
10
+ it('reads params from the url', () => {
11
+ const urlParamStore = createUrlParamStore()
12
+ const originalFilterInfo = buildDefaultFilterInfo({
13
+ filter: {
14
+ foo: 'bar',
15
+ baz: 'qux',
16
+ },
17
+ page: 20,
18
+ pageSize: 5,
19
+ })
20
+
21
+ // Nothing has been saved, so `getFilter` should return nothing.
22
+ expect(urlParamStore.getFilter(FILTER_NAME)).toBeFalsy()
23
+
24
+ // We need some params to actually test, so calling `.saveFilter` sets this
25
+ // up for us.
26
+ urlParamStore.saveFilter(FILTER_NAME, originalFilterInfo)
27
+
28
+ // Now we can see what we get back.
29
+ const parsedFilterInfo = urlParamStore.getFilter(FILTER_NAME)
30
+
31
+ expect(parsedFilterInfo).not.toBeFalsy()
32
+
33
+ // We save a subset of the original filterInfo, so we just need to check
34
+ // that the original contains whatever we've parsed.
35
+ expect(originalFilterInfo).toMatchObject(parsedFilterInfo!)
36
+ })
37
+
38
+ it('updates url params on save', () => {
39
+ const urlParamStore = createUrlParamStore()
40
+ urlParamStore.saveFilter(
41
+ FILTER_NAME,
42
+ buildDefaultFilterInfo({ filter: { foo: 'bar' } }),
43
+ )
44
+
45
+ const newUrl = window.location.toString()
46
+ expect(newUrl).toContain(encodeURI(`filter.test[foo]=bar`))
47
+ expect(newUrl).toContain(encodeURI(`info.test[pageSize]=20`))
48
+ expect(newUrl).toContain(encodeURI(`info.test[offset]=0`))
49
+ expect(newUrl).toContain(encodeURI(`info.test[page]=1`))
50
+ })
51
+
52
+ it('supports .clear()', () => {
53
+ const urlParamStore = createUrlParamStore()
54
+ const originalFilterInfo = buildDefaultFilterInfo({
55
+ filter: {
56
+ foo: 'bar',
57
+ baz: 'qux',
58
+ },
59
+ page: 20,
60
+ pageSize: 5,
61
+ })
62
+
63
+ // We need some params to actually test, so calling `.saveFilter` sets this
64
+ // up for us.
65
+ urlParamStore.saveFilter(FILTER_NAME, originalFilterInfo)
66
+
67
+ // Since we've saved the filter, there should be query params.
68
+ expect(window.location.search).toBeTruthy()
69
+
70
+ // And when we clear it, it should be empty.
71
+ urlParamStore.clear()
72
+ expect(window.location.search).toBeFalsy()
73
+ })
74
+
75
+ it('reads a valid filter with incomplete filter info from url', () => {
76
+ const urlParamStore = createUrlParamStore()
77
+ const originalFilterInfo = {
78
+ filter: {
79
+ foo: 'bar',
80
+ },
81
+ info: undefined,
82
+ }
83
+ const { filter, ...info } = originalFilterInfo
84
+ replaceQueryParams(
85
+ qs.stringify({
86
+ [`filter.${FILTER_NAME}`]: filter,
87
+ [`info.${FILTER_NAME}`]: info,
88
+ }),
89
+ )
90
+
91
+ const parsedFilterInfo = urlParamStore.getFilter(FILTER_NAME)
92
+ expect(parsedFilterInfo).not.toBeFalsy()
93
+ expect(originalFilterInfo).toEqual(parsedFilterInfo!)
94
+ })
95
+ it('reads a valid filter info with incomplete filter from url', () => {
96
+ const urlParamStore = createUrlParamStore()
97
+ const originalFilterInfo = {
98
+ filter: undefined,
99
+ page: 2,
100
+ }
101
+ const { filter, ...info } = originalFilterInfo
102
+ replaceQueryParams(
103
+ qs.stringify({
104
+ [`filter.${FILTER_NAME}`]: filter,
105
+ [`info.${FILTER_NAME}`]: info,
106
+ }),
107
+ )
108
+
109
+ const parsedFilterInfo = urlParamStore.getFilter(FILTER_NAME)
110
+ expect(parsedFilterInfo).not.toBeFalsy()
111
+ expect(originalFilterInfo).toEqual(parsedFilterInfo!)
112
+ })
113
+ it('reads a unrelated filter props without breaking', () => {
114
+ const urlParamStore = createUrlParamStore()
115
+ const originalFilterInfo = {
116
+ filter: undefined,
117
+ info: undefined,
118
+ }
119
+ const { filter, ...info } = originalFilterInfo
120
+ replaceQueryParams(
121
+ qs.stringify({
122
+ [`filter.${FILTER_NAME}`]: filter,
123
+ [`info.${FILTER_NAME}`]: info,
124
+ someBogusProp: 'foo',
125
+ }),
126
+ )
127
+
128
+ const parsedFilterInfo = urlParamStore.getFilter(FILTER_NAME)
129
+ expect(parsedFilterInfo).toBeFalsy()
130
+ })
131
+ })
@@ -0,0 +1,85 @@
1
+ import qs from 'qs'
2
+
3
+ import type { FilterStore } from './index'
4
+
5
+ export const createUrlParamStore = () => {
6
+ const urlParamStore: FilterStore = {
7
+ getFilter(namespace) {
8
+ const parsed = naivelyParseExistingParams()
9
+
10
+ const parsedInfo = parsed[`info.${namespace}`]
11
+ const parsedFilter = parsed[`filter.${namespace}`]
12
+
13
+ // useFilter doesn't really support returning a partial FilterInfo, so both
14
+ // need to be present,
15
+ if (parsedInfo || parsedFilter) {
16
+ const { page, offset, pageSize, ...rest } = parsedInfo || {}
17
+
18
+ return {
19
+ ...{
20
+ // cast string to number or undefined
21
+ page: 1 / page ? +page : undefined,
22
+ pageSize: 1 / pageSize ? +pageSize : undefined,
23
+ offset: 1 / offset ? +offset : undefined,
24
+ ...rest,
25
+ },
26
+ filter: parsedFilter,
27
+ }
28
+ }
29
+ },
30
+
31
+ saveFilter(namespace, filterInfo) {
32
+ const parsed = naivelyParseExistingParams()
33
+
34
+ // Setting the params to the equivalent of { [namespace]: filterInfo }
35
+ // takes up more room. The idea is that this ...
36
+ // 'filter.filterName%5BfilterValueKey%5D=filterValueValue'
37
+ // ... is smaller than ...
38
+ // filterName%5Bfilter%5D%5BfilterValueKey%5D=filterValueValue
39
+ // ... by 5 characters per value in a filter. So excuse us for a second ...
40
+ const clonedInfo: Partial<typeof filterInfo> = { ...filterInfo }
41
+ const clonedFilter = filterInfo.filter
42
+ delete clonedInfo.filter
43
+ delete clonedInfo.lastRefreshAt
44
+ delete clonedInfo.shouldRunImmediately
45
+ delete clonedInfo.totalResults
46
+ delete clonedInfo.totalPages
47
+ delete clonedInfo.nextCursor
48
+ parsed[`filter.${namespace}`] = clonedFilter
49
+ parsed[`info.${namespace}`] = clonedInfo
50
+
51
+ replaceQueryParams(qs.stringify(parsed))
52
+ },
53
+
54
+ clear() {
55
+ const parsed = naivelyParseExistingParams()
56
+
57
+ for (const key in parsed) {
58
+ if (key.startsWith('filter.') || key.startsWith('info.')) {
59
+ delete parsed[key]
60
+ }
61
+ }
62
+
63
+ replaceQueryParams(qs.stringify(parsed))
64
+ },
65
+
66
+ getData() {
67
+ return undefined
68
+ },
69
+ saveData() {},
70
+ }
71
+
72
+ return urlParamStore
73
+ }
74
+
75
+ export function replaceQueryParams(newParams: string) {
76
+ const nextUrl = new URL(window.location.toString())
77
+ nextUrl.search = newParams
78
+ window.history.replaceState(undefined, '', nextUrl.toString())
79
+ }
80
+
81
+ export function naivelyParseExistingParams() {
82
+ // Don't really want to do any casting, especially not to `any`, but the type
83
+ // of `qs.parse` is so wide we'd have to litter checks all over the place.
84
+ return qs.parse(window.location.search.substring(1)) as any
85
+ }