react-magic-search-params 1.2.0 → 2.1.2

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.
@@ -1,526 +0,0 @@
1
- import { useSearchParams } from 'react-router-dom'
2
- import { useMemo, useEffect, useRef, useCallback } from 'react'
3
-
4
-
5
- // Custom hook with advanced techniques to handle search parameters for any pagination
6
-
7
- type CommonParams = {
8
- page?: number
9
- page_size?: number
10
- }
11
- /*
12
- Maps all properties of M (mandatory) as required
13
- and all properties of O (optional) as optional.
14
- */
15
- type MergeParams<M, O> = {
16
- [K in keyof M]: M[K]
17
- } & {
18
- [K in keyof O]?: O[K]
19
- }
20
- /**
21
- * Interface for the configuration object that the hook receives
22
- */
23
- export interface UseMagicSearchParamsOptions<
24
- M extends Record<string, unknown>,
25
- O extends Record<string, unknown>
26
- > {
27
- mandatory: M
28
- optional?: O
29
- defaultParams?: Partial<MergeParams<M, O>>
30
- forceParams?: Partial<MergeParams<M, O>> // transform all to partial to avoid errors
31
- arraySerialization?: 'csv' | 'repeat' | 'brackets' // technical to serialize arrays in the URL
32
- omitParamsByValues?: Array<'all' | 'default' | 'unknown' | 'none' | 'void '>
33
- }
34
-
35
- /**
36
- Generic hook to handle search parameters in the URL
37
- @param mandatory - Mandatory parameters (e.g., page=1, page_size=10, etc.)
38
- @param optional - Optional parameters (e.g., order, search, etc.)
39
- @param defaultParams - Default parameters sent in the URL on initialization
40
- @param forceParams - Parameters forced into the URL regardless of user input
41
- @param omitParamsByValues - Parameters omitted if they have specific values
42
- */
43
- export const useMagicSearchParams = <
44
- M extends Record<string, unknown> & CommonParams,
45
- O extends Record<string, unknown>,
46
- >({
47
- mandatory = {} as M,
48
- optional = {} as O,
49
- defaultParams = {} as Partial<MergeParams<M, O>>,
50
- arraySerialization = 'csv',
51
- forceParams = {} as {} as Partial<MergeParams<M, O>>,
52
- omitParamsByValues = [] as Array<'all' | 'default' | 'unknown' | 'none' | 'void '>
53
- }: UseMagicSearchParamsOptions<M, O>)=> {
54
-
55
- const [searchParams, setSearchParams] = useSearchParams()
56
- // Ref to store subscriptions: { paramName: [callback1, callback2, ...] }
57
- const subscriptionsRef = useRef<Record<string, Array<() => unknown>>>({});
58
- const previousParamsRef = useRef<Record<string, unknown>>({})
59
-
60
- const TOTAL_PARAMS_PAGE: MergeParams<M, O> = useMemo(() => {
61
- return { ...mandatory, ...optional };
62
- }, [mandatory, optional]);
63
-
64
- const PARAM_ORDER = useMemo(() => {
65
- return Array.from(Object.keys(TOTAL_PARAMS_PAGE))
66
- }, [TOTAL_PARAMS_PAGE])
67
-
68
- // we get the keys that are arrays according to TOTAL_PARAMS_PAGE since these require special treatment in the URL due to serialization mode
69
- const ARRAY_KEYS = useMemo(() => {
70
- return Object.keys(TOTAL_PARAMS_PAGE).filter(
71
- (key) => Array.isArray(TOTAL_PARAMS_PAGE[key])
72
- );
73
- }, [TOTAL_PARAMS_PAGE])
74
-
75
- const appendArrayValues = (
76
- finallyParams: Record<string, unknown>,
77
- newParams: Record<string, string | string[] | unknown>
78
- ): Record<string, unknown> => {
79
-
80
- // Note: We cannot modify the object of the final parameters directly, as immutability must be maintained
81
- const updatedParams = { ...finallyParams };
82
-
83
- if (ARRAY_KEYS.length === 0) return updatedParams;
84
-
85
- ARRAY_KEYS.forEach((key) => {
86
- // We use the current values directly from searchParams (source of truth)
87
- // This avoids depending on finallyParams in which the arrays have been omitted
88
- let currentValues = [];
89
- switch (arraySerialization) {
90
- case 'csv': {
91
- const raw = searchParams.get(key) || '';
92
- // For csv we expect "value1,value2,..." (no prefix)
93
- currentValues = raw.split(',')
94
- .map((v) => v.trim())
95
- .filter(Boolean) as Array<string>
96
- break;
97
- }
98
- case 'repeat': {
99
- // Here we get all ocurrences of key
100
- const urlParams = searchParams.getAll(key) as Array<string>
101
- currentValues = urlParams.length > 0 ? urlParams : []
102
-
103
- break;
104
- }
105
- case 'brackets': {
106
- // Build URLSearchParams from current parameters (to ensure no serialized values are taken previously)
107
- const urlParams = searchParams.getAll(`${key}[]`) as Array<string>
108
- currentValues = urlParams.length > 0 ? urlParams : []
109
-
110
- break;
111
- }
112
- default: {
113
- // Mode by default works as csv
114
- const raw = searchParams.get(key) ?? '';
115
- currentValues = raw.split(',')
116
- .map((v) => v.trim())
117
- .filter(Boolean);
118
- }
119
- break;
120
- }
121
- // Update array values with new ones
122
-
123
- if (newParams[key] !== undefined) {
124
- const incoming = newParams[key];
125
- let combined: string[] = []
126
- if (typeof incoming === 'string') {
127
- // If it is a string, it is toggled (add/remove)
128
- combined = currentValues.includes(incoming)
129
- ? currentValues.filter((v) => v !== incoming)
130
- : [...currentValues, incoming];
131
- } else if (Array.isArray(incoming)) {
132
- // if an array is passed, repeated values are merged into a single value
133
- // Note: Set is used to remove duplicates
134
- combined = Array.from(new Set([ ...incoming]));
135
-
136
- } else {
137
-
138
- combined = currentValues;
139
- }
140
-
141
- updatedParams[key] = combined
142
-
143
- }
144
- });
145
- return updatedParams
146
- };
147
-
148
- const transformParamsToURLSearch = (params: Record<string, unknown>): URLSearchParams => {
149
- console.log({PARAMS_RECIBIDOS_TRANSFORM: params})
150
-
151
- const newParam: URLSearchParams = new URLSearchParams()
152
-
153
- const paramsKeys = Object.keys(params)
154
-
155
- for (const key of paramsKeys) {
156
- if (Array.isArray(TOTAL_PARAMS_PAGE[key])) {
157
- const arrayValue = params[key] as unknown[]
158
- console.log({arrayValue})
159
- switch (arraySerialization) {
160
- case 'csv': {
161
- const csvValue = arrayValue.join(',')
162
- // set ensure that the previous value is replaced
163
- newParam.set(key, csvValue)
164
- break
165
- } case 'repeat': {
166
-
167
- for (const item of arrayValue) {
168
-
169
- // add new value to the key, instead of replacing it
170
- newParam.append(key, item as string)
171
-
172
- }
173
- break
174
- } case 'brackets': {
175
- for (const item of arrayValue) {
176
- newParam.append(`${key}[]`, item as string)
177
- }
178
- break
179
- } default: {
180
- const csvValue = arrayValue.join(',')
181
- newParam.set(key, csvValue)
182
- }
183
- }
184
- } else {
185
- newParam.set(key, params[key] as string)
186
- }
187
- }
188
- return newParam
189
- }
190
- // @ts-ignore
191
- const hasForcedParamsValues = ({ paramsForced, compareParams }) => {
192
-
193
- // Iterates over the forced parameters and verifies that they exist in the URL and match their values
194
- // Ej: { page: 1, page_size: 10 } === { page: 1, page_size: 10 } => true
195
- const allParamsMatch = Object.entries(paramsForced).every(
196
- ([key, value]) => compareParams[key] === value
197
- );
198
-
199
- return allParamsMatch;
200
- };
201
-
202
- useEffect(() => {
203
-
204
- const keysDefaultParams: string[] = Object.keys(defaultParams)
205
- const keysForceParams: string[] = Object.keys(forceParams)
206
- if(keysDefaultParams.length === 0 && keysForceParams.length === 0) return
207
-
208
-
209
- function handleStartingParams() {
210
-
211
- const defaultParamsString = transformParamsToURLSearch(defaultParams).toString()
212
- const paramsUrl = getParams()
213
- const paramsUrlString = transformParamsToURLSearch(paramsUrl).toString()
214
- const forceParamsString = transformParamsToURLSearch(forceParams).toString()
215
-
216
- console.log({defaultParamsString})
217
-
218
- const isForcedParams: boolean = hasForcedParamsValues({ paramsForced: forceParams, compareParams: paramsUrl })
219
-
220
- if (!isForcedParams) {
221
-
222
- // In this case, the forced parameters take precedence over the default parameters and the parameters of the current URL (which could have been modified by the user, e.g., page_size=1000)
223
-
224
- updateParams({ newParams: {
225
- ...defaultParams,
226
- ...forceParams
227
- }})
228
- return
229
- }
230
- // In this way it will be validated that the forced parameters keys and values are in the current URL
231
- const isIncludesForcedParams = hasForcedParamsValues({ paramsForced: forceParamsString, compareParams: defaultParams })
232
-
233
- if (keysDefaultParams.length > 0 && isIncludesForcedParams) {
234
- if (defaultParamsString === paramsUrlString) return // this means that the URL already has the default parameters
235
- updateParams({ newParams: defaultParams })
236
- }
237
-
238
- }
239
- handleStartingParams()
240
-
241
- // eslint-disable-next-line react-hooks/exhaustive-deps
242
- }, [])
243
-
244
- /**
245
- * Convert a string value to its original type (number, boolean, array) according to TOTAL_PARAMS_PAGE
246
- * @param value - Chain obtained from the URL
247
- * @param key - Key of the parameter
248
- */
249
- const convertOriginalType = (value: string, key: string) => {
250
- // Given that the parameters of a URL are recieved as strings, they are converted to their original type
251
- if (typeof TOTAL_PARAMS_PAGE[key] === 'number') {
252
- return parseInt(value)
253
- } else if (typeof TOTAL_PARAMS_PAGE[key] === 'boolean') {
254
- return value === 'true'
255
- } else if (Array.isArray(TOTAL_PARAMS_PAGE[key])) {
256
- // The result will be a valid array represented in the URL ej: tags=tag1,tag2,tag3 to ['tag1', 'tag2', 'tag3'], useful to combine the values of the arrays with the new ones
257
-
258
- if (arraySerialization === 'csv') {
259
- return searchParams.getAll(key).join('').split(',')
260
- } else if (arraySerialization === 'repeat') {
261
-
262
- console.log({SEARCH_PARAMS: searchParams.getAll(key)})
263
- return searchParams.getAll(key)
264
- } else if (arraySerialization === 'brackets') {
265
- return searchParams.getAll(`${key}[]`)
266
- }
267
-
268
-
269
- }
270
- // Note: dates are not converted as it is better to handle them directly in the component that receives them, using a library like < date-fns >
271
- return value
272
- }
273
-
274
- /**
275
- * Gets the current parameters from the URL and converts them to their original type if desired
276
- * @param convert - If true, converts from string to the inferred type (number, boolean, ...)
277
- */
278
- const getStringUrl = (key: string, paramsUrl: Record<string, unknown>) => {
279
- const isKeyArray = Array.isArray(TOTAL_PARAMS_PAGE[key])
280
- if (isKeyArray) {
281
-
282
- if (arraySerialization === 'brackets') {
283
-
284
- const arrayUrl = searchParams.getAll(`${key}[]`)
285
- const encodedQueryArray = transformParamsToURLSearch({ [key]: arrayUrl }).toString()
286
- // in this way the array of the URL is decoded to its original form ej: tags[]=tag1&tags[]=tag2&tags[]=tag3
287
- const unencodeQuery = decodeURIComponent(encodedQueryArray)
288
- return unencodeQuery
289
- } else if (arraySerialization === 'csv') {
290
- const arrayValue = searchParams.getAll(key)
291
- const encodedQueryArray = transformParamsToURLSearch({ [key]: arrayValue }).toString()
292
- const unencodeQuery = decodeURIComponent(encodedQueryArray)
293
- return unencodeQuery
294
- }
295
- const arrayValue = searchParams.getAll(key)
296
- const stringResult = transformParamsToURLSearch({ [key]: arrayValue }).toString()
297
- return stringResult
298
- } else {
299
-
300
- return paramsUrl[key] as string
301
- }
302
- }
303
- const getParamsObj = (searchParams: URLSearchParams): Record<string, string | string[]> => {
304
- const paramsObj: Record<string, string | string[]> = {};
305
- // @ts-ignore
306
- for (const [key, value] of searchParams.entries()) {
307
- if (key.endsWith('[]')) {
308
- const bareKey = key.replace('[]', '');
309
- if (paramsObj[bareKey]) {
310
- (paramsObj[bareKey] as string[]).push(value);
311
- } else {
312
- paramsObj[bareKey] = [value];
313
- }
314
- } else {
315
- // If the key already exists, it is a repeated parameter
316
- if (paramsObj[key]) {
317
- if (Array.isArray(paramsObj[key])) {
318
- (paramsObj[key] as string[]).push(value);
319
- } else {
320
- paramsObj[key] = [paramsObj[key] as string, value];
321
- }
322
- } else {
323
- paramsObj[key] = value;
324
- }
325
- }
326
- }
327
- return paramsObj;
328
- }
329
- // Optimization: While the parameters are not updated, the current parameters of the URL are not recalculated
330
- const CURRENT_PARAMS_URL: Record<string, unknown> = useMemo(() => {
331
-
332
- return arraySerialization === 'brackets' ? getParamsObj(searchParams) : Object.fromEntries(searchParams.entries())
333
- }, [searchParams, arraySerialization])
334
-
335
- /**
336
- * Gets the current parameters from the URL and converts them to their original type if desired
337
- * @param convert - If true, converts from string to the inferred type (number, boolean, ...)
338
- * @returns - Returns the current parameters of the URL
339
- */
340
- const getParams = ({ convert = true } = {}): MergeParams<M, O> => {
341
- // All the paramteres are extracted from the URL and converted into an object
342
-
343
- const params = Object.keys(CURRENT_PARAMS_URL).reduce((acc, key) => {
344
- if (Object.prototype.hasOwnProperty.call(TOTAL_PARAMS_PAGE, key)) {
345
- const realKey = arraySerialization === 'brackets' ? key.replace('[]', '') : key
346
- // @ts-ignore
347
- acc[realKey] = convert === true
348
- ? convertOriginalType(CURRENT_PARAMS_URL[key] as string, key)
349
- : getStringUrl(key, CURRENT_PARAMS_URL)
350
- }
351
- return acc
352
- }, {})
353
-
354
- return params as MergeParams<M, O>
355
- }
356
- type keys = keyof MergeParams<M, O>
357
- // Note: in this way the return of the getParam function is typed dynamically, thus having autocomplete in the IDE (eg: value.split(','))
358
- type TagReturn<T extends boolean> = T extends true ? string[] : string;
359
- /**
360
- * Gets the value of a parameter from the URL and converts it to its original type if desired
361
- * @param key - Key of the parameter
362
- * @param options - Options to convert the value to its original type, default is true
363
- * @returns - Returns the value of the parameter
364
- */
365
-
366
- const getParam = <T extends boolean>(key: keys, options?: { convert: T }): TagReturn<T> => {
367
-
368
- const keyStr = String(key)
369
- // @ts-ignore
370
- const value = options?.convert === true ? convertOriginalType(searchParams.get(keyStr), keyStr) : getStringUrl(keyStr, CURRENT_PARAMS_URL)
371
- return value as TagReturn<T>
372
- }
373
-
374
- type OptionalParamsFiltered = Partial<O>
375
-
376
- const calculateOmittedParameters = (newParams: Record<string, unknown | unknown[]>, keepParams: Record<string, boolean>) => {
377
- // Calculate the ommited parameters, that is, the parameters that have not been sent in the request
378
- const params = getParams()
379
- // hasOw
380
- // Note: it will be necessary to omit the parameters that are arrays because the idea is not to replace them but to add or remove some values
381
- const newParamsWithoutArray = Object.entries(newParams).filter(([key,]) => !Array.isArray(TOTAL_PARAMS_PAGE[key]))
382
- const result = Object.assign({
383
- ...params,
384
- ...Object.fromEntries(newParamsWithoutArray),
385
- ...forceParams // the forced parameters will always be sent and will maintain their value
386
- })
387
- const paramsFiltered: OptionalParamsFiltered = Object.keys(result).reduce((acc, key) => {
388
- // for default no parameters are omitted unless specified in the keepParams object
389
- if (Object.prototype.hasOwnProperty.call(keepParams, key) && keepParams[key] === false) {
390
- return acc
391
- // Note: They array of parameters omitted by values (e.g., ['all', 'default']) are omitted since they are usually a default value that is not desired to be sent
392
- } else if (!!result[key] !== false && !omitParamsByValues.includes(result[key])) {
393
- // @ts-ignore
394
- acc[key] = result[key]
395
- }
396
-
397
- return acc
398
- }, {})
399
-
400
- return {
401
- ...mandatory,
402
- ...paramsFiltered
403
- }
404
- }
405
- // @ts-ignore
406
- const sortParameters = (paramsFiltered) => {
407
- // sort the parameters according to the structure so that it persists with each change in the URL, eg: localhost:3000/?page=1&page_size=10
408
- // Note: this visibly improves the user experience
409
- const orderedParams = PARAM_ORDER.reduce((acc, key) => {
410
- if (Object.prototype.hasOwnProperty.call(paramsFiltered, key)) {
411
- // @ts-ignore
412
- acc[key] = paramsFiltered[key]
413
- }
414
-
415
- return acc
416
- }, {})
417
- return orderedParams
418
- }
419
-
420
- const mandatoryParameters = () => {
421
- // Note: in case there are arrays in the URL, they are converted to their original form ej: tags=['tag1', 'tag2'] otherwise the parameters are extracted without converting to optimize performance
422
- const isNecessaryConvert: boolean = ARRAY_KEYS.length > 0 ? true : false
423
- const totalParametros: Record<string, unknown> = getParams({ convert: isNecessaryConvert })
424
-
425
- const paramsUrlFound: Record<string, boolean> = Object.keys(totalParametros).reduce(
426
- (acc, key) => {
427
- if (Object.prototype.hasOwnProperty.call(mandatory, key)) {
428
- // @ts-ignore
429
- acc[key] = totalParametros[key]
430
- }
431
- return acc
432
- },
433
- {}
434
- )
435
-
436
- return paramsUrlFound
437
- }
438
- /**
439
- clears the parameters of the URL, keeping the mandatory parameters
440
- * @param keepMandatoryParams - If true, the mandatory parameters are kept in the URL
441
- */
442
-
443
- const clearParams = ({ keepMandatoryParams = true } = {}): void => {
444
- // for default, the mandatory parameters are not cleared since the current pagination would be lost
445
- const paramsTransformed = transformParamsToURLSearch(
446
- {
447
- ...mandatory,
448
-
449
- ...(keepMandatoryParams && {
450
- ...mandatoryParameters()
451
- }),
452
- ...forceParams
453
- }
454
- )
455
- setSearchParams(paramsTransformed)
456
- }
457
-
458
- // transforms the keys to boolean to know which parameters to keep
459
- type KeepParamsTransformedValuesBoolean = Partial<Record<keyof typeof TOTAL_PARAMS_PAGE, boolean>>
460
- type NewParams = Partial<typeof TOTAL_PARAMS_PAGE>
461
- type KeepParams = KeepParamsTransformedValuesBoolean
462
- /**
463
- Merges the new parameters with the current ones, omits the parameters that are not sent and sorts them according to the structure
464
- * @param newParams - New parameters to be sent in the URL
465
- * @param keepParams - Parameters to keep in the URL, default is true
466
- */
467
- const updateParams = ({ newParams = {} as NewParams, keepParams = {} as KeepParams } = {}) => {
468
-
469
- if (
470
- Object.keys(newParams).length === 0 &&
471
- Object.keys(keepParams).length === 0
472
- ) {
473
- clearParams()
474
- return
475
- }
476
- // @ts-ignore
477
- const finallyParamters = calculateOmittedParameters(newParams, keepParams)
478
-
479
- const convertedArrayValues = appendArrayValues(finallyParamters, newParams)
480
-
481
- const paramsSorted = sortParameters(convertedArrayValues)
482
-
483
- setSearchParams(transformParamsToURLSearch(paramsSorted))
484
-
485
- }
486
-
487
- /**
488
- * @param paramName - Name of the parameter to subscribe to
489
- * @param callbacks - Callbacks to be executed when the parameter changes
490
- * @returns - Returns the function to unsubscribe
491
- */
492
- const onChange = useCallback( (paramName: keys, callbacks: Array<() => void>) => {
493
- const paramNameStr = String(paramName)
494
- // replace the previous callbacks with the new ones so as not to accumulate callbacks
495
- subscriptionsRef.current[paramNameStr] = callbacks;
496
- }, [])
497
-
498
- // each time searchParams changes, we notify the subscribers
499
- useEffect(() => {
500
-
501
- for (const [key, value] of Object.entries(subscriptionsRef.current)) {
502
-
503
- const newValue = CURRENT_PARAMS_URL[key] ?? null
504
- const oldValue = previousParamsRef.current[key] ?? null
505
- if (newValue !== oldValue) {
506
-
507
- for (const callback of value) {
508
- callback()
509
-
510
- }
511
- }
512
- // once the callback is executed, the previous value is updated to ensure that the next time the value changes, the callback is executed
513
- previousParamsRef.current[key] = newValue
514
- }
515
-
516
- }, [CURRENT_PARAMS_URL])
517
- return {
518
- searchParams,
519
- updateParams,
520
- clearParams,
521
- getParams,
522
- getParam,
523
- onChange
524
- }
525
- }
526
-