resources/assets/js/hooks/webResourceHooks/indexCrudReducer.ts   A
last analyzed

Complexity

Total Complexity 28
Complexity/F 2.55

Size

Lines of Code 531
Function Count 11

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
wmc 28
eloc 349
mnd 17
bc 17
fnc 11
dl 0
loc 531
rs 10
bpm 1.5454
cpm 2.5454
noi 0
c 0
b 0
f 0

11 Functions

Rating   Name   Duplication   Size   Complexity  
A indexCrudReducer.ts ➔ mergeIndexItem 0 25 3
A indexCrudReducer.ts ➔ mergeUpdateFulfill 0 30 3
A indexCrudReducer.ts ➔ mergeCreatePayload 0 33 3
C indexCrudReducer.ts ➔ reducer 0 137 6
A indexCrudReducer.ts ➔ mergeUpdateReject 0 24 3
A indexCrudReducer.ts ➔ mergeUpdateStart 0 26 2
A indexCrudReducer.ts ➔ mergeDeleteFulfill 0 16 1
A indexCrudReducer.ts ➔ initializeState 0 25 1
A indexCrudReducer.ts ➔ mergeIndexPayload 0 18 1
A indexCrudReducer.ts ➔ mergeDeleteReject 0 24 3
A indexCrudReducer.ts ➔ mergeDeleteStart 0 22 2
1
import { FetchError } from "../../helpers/httpRequests";
2
import {
3
  decrement,
4
  deleteProperty,
5
  filterObjectProps,
6
  getId,
7
  hasKey,
8
  mapToObjectTrans,
9
} from "../../helpers/queries";
10
import { ResourceStatus } from "./types";
11
12
export enum ActionTypes {
13
  IndexStart = "INDEX_START",
14
  IndexFulfill = "INDEX_FULFILL",
15
  IndexReject = "INDEX_REJECT",
16
17
  CreateStart = "CREATE_START",
18
  CreateFulfill = "CREATE_FULFILL",
19
  CreateReject = "CREATE_REJECT",
20
21
  UpdateStart = "UPDATE_START",
22
  UpdateFulfill = "UPDATE_FULFILL",
23
  UpdateReject = "UPDATE_REJECT",
24
25
  DeleteStart = "DELETE_START",
26
  DeleteFulfill = "DELETE_FULFILL",
27
  DeleteReject = "DELETE_REJECT",
28
}
29
30
export type IndexStartAction = { type: ActionTypes.IndexStart };
31
export type IndexFulfillAction<T> = {
32
  type: ActionTypes.IndexFulfill;
33
  payload: { item: T; key: string | number }[];
34
};
35
export type IndexRejectAction = {
36
  type: ActionTypes.IndexReject;
37
  payload: Error | FetchError;
38
};
39
40
export type CreateStartAction<T> = {
41
  type: ActionTypes.CreateStart;
42
  meta: { item: T };
43
};
44
export type CreateFulfillAction<T> = {
45
  type: ActionTypes.CreateFulfill;
46
  payload: { item: T; key: string | number };
47
  meta: { item: T };
48
};
49
export type CreateRejectAction<T> = {
50
  type: ActionTypes.CreateReject;
51
  payload: Error | FetchError;
52
  meta: { item: T };
53
};
54
55
export type UpdateStartAction<T> = {
56
  type: ActionTypes.UpdateStart;
57
  meta: { key: string | number; item: T };
58
};
59
export type UpdateFulfillAction<T> = {
60
  type: ActionTypes.UpdateFulfill;
61
  payload: T;
62
  meta: { key: string | number; item: T };
63
};
64
export type UpdateRejectAction<T> = {
65
  type: ActionTypes.UpdateReject;
66
  payload: Error | FetchError;
67
  meta: { key: string | number; item: T };
68
};
69
70
export type DeleteStartAction = {
71
  type: ActionTypes.DeleteStart;
72
  meta: { key: string | number };
73
};
74
export type DeleteFulfillAction = {
75
  type: ActionTypes.DeleteFulfill;
76
  meta: { key: string | number };
77
};
78
export type DeleteRejectAction = {
79
  type: ActionTypes.DeleteReject;
80
  payload: Error | FetchError;
81
  meta: { key: string | number };
82
};
83
export type AsyncAction<T> =
84
  | IndexStartAction
85
  | IndexFulfillAction<T>
86
  | IndexRejectAction
87
  | CreateStartAction<T>
88
  | CreateFulfillAction<T>
89
  | CreateRejectAction<T>
90
  | UpdateStartAction<T>
91
  | UpdateFulfillAction<T>
92
  | UpdateRejectAction<T>
93
  | DeleteStartAction
94
  | DeleteFulfillAction
95
  | DeleteRejectAction;
96
97
export interface ResourceState<T> {
98
  indexMeta: {
99
    status: ResourceStatus;
100
    pendingCount: number;
101
    error: Error | FetchError | undefined;
102
    initialRefreshFinished: boolean;
103
  };
104
  createMeta: {
105
    status: ResourceStatus;
106
    pendingCount: number;
107
    error: Error | FetchError | undefined; // Only stores the most recent error;
108
  };
109
  values: {
110
    [key: string]: {
111
      value: T;
112
      error: Error | FetchError | undefined;
113
      status: ResourceStatus;
114
      pendingCount: number;
115
    };
116
  };
117
}
118
119
export function initializeState<T>(
120
  items: { item: T; key: string | number }[],
121
  doInitialRefresh = true,
122
): ResourceState<T> {
123
  return {
124
    indexMeta: {
125
      status: "initial",
126
      pendingCount: 0,
127
      error: undefined,
128
      initialRefreshFinished: !doInitialRefresh,
129
    },
130
    createMeta: {
131
      status: "initial",
132
      pendingCount: 0,
133
      error: undefined,
134
    },
135
    values: mapToObjectTrans(
136
      items,
137
      (x) => x.key,
138
      (x) => ({
139
        value: x.item,
140
        error: undefined,
141
        status: "initial",
142
        pendingCount: 0,
143
      }),
144
    ),
145
  };
146
}
147
148
type StateValues<T> = ResourceState<T>["values"];
149
150
function mergeIndexItem<T>(
151
  values: StateValues<T>,
152
  { item, key }: { item: T; key: string | number },
153
): StateValues<T> {
154
  if (hasKey(values, key)) {
155
    // We leave the pending count as is, in case an update or delete is in progress for this item.
156
    // We do overwrite errors, and set status to "fulfilled" if it was "initial" or "rejected"
157
    return {
158
      ...values,
159
      [key]: {
160
        ...values[key],
161
        value: item,
162
        status: values[key].status === "pending" ? "pending" : "fulfilled",
163
        error: undefined,
164
      },
165
    };
166
  }
167
  return {
168
    ...values,
169
    [key]: {
170
      value: item,
171
      error: undefined,
172
      status: "fulfilled",
173
      pendingCount: 0,
174
    },
175
  };
176
}
177
178
/**
179
 * Updates values in response to INDEX FULFILLED action:
180
 *   - Updates the value of existing items without modifying item-specific metadata (related to UPDATE and DELETE requests).
181
 *   - Creates new items (with "fulfilled" status metadata).
182
 *   - Deletes existing state items that are not part of the new payload.
183
 * @param values
184
 * @param payload
185
 */
186
function mergeIndexPayload<T>(
187
  values: StateValues<T>,
188
  payload: { item: T; key: string | number }[],
189
): StateValues<T> {
190
  // Update or create a values entry for each item in the payload.
191
  const newValues = payload.reduce(mergeIndexItem, values);
192
  // Delete any values entries that don't exist in the new payload.
193
  const payloadKeys = payload.map((x) => String(x.key)); // If x.key is a number, cast it to a string so it can be compared to key in filterObjectProps
194
  return filterObjectProps(newValues, (_, key) => payloadKeys.includes(key));
195
}
196
197
/**
198
 * Updates values in response to CREATE FULFILLED action.
199
 * - Adds the new item to values, with "fulfilled" status.
200
 * - Note: If newly created item has the same key as an existing item, update that item instead.
201
 *    This should never happen during normal interaction with a REST api.
202
 * @param values
203
 * @param payload
204
 */
205
function mergeCreatePayload<T>(
206
  values: StateValues<T>,
207
  payload: { item: T; key: string | number },
208
): StateValues<T> {
209
  if (hasKey(values, payload.key)) {
210
    // It doesn't really make sense for the result of a create request to already exist...
211
    // But we have to trust the latest response from the server. Update the existing item.
212
    return {
213
      ...values,
214
      [payload.key]: {
215
        value: payload.item,
216
        status: values[payload.key].pendingCount <= 1 ? "fulfilled" : "pending",
217
        pendingCount: decrement(values[payload.key].pendingCount),
218
        error: undefined,
219
      },
220
    };
221
  }
222
  return {
223
    ...values,
224
    [payload.key]: {
225
      value: payload.item,
226
      error: undefined,
227
      status: "fulfilled",
228
      pendingCount: 0,
229
    },
230
  };
231
}
232
233
/**
234
 * Updates values in response to UPDATE START action.
235
 * - Updates metadata for updated item.
236
 * - Does nothing if the item does not yet exist.
237
 * @param values
238
 * @param action
239
 */
240
function mergeUpdateStart<T>(
241
  values: StateValues<T>,
242
  action: UpdateStartAction<T>,
243
): StateValues<T> {
244
  if (!hasKey(values, action.meta.key)) {
245
    // Do not update values. We don't want to create a new value in case the request fails and it doesn't represent anything on the server.
246
    // NOTE: if we move to optimistic updates, we should add to values here.
247
    return values;
248
  }
249
  return {
250
    ...values,
251
    [action.meta.key]: {
252
      // TODO: if we wanted to do an optimistic update, we could save action.payload.item here.
253
      // But we would need some way to reverse it if it failed.
254
      ...values[action.meta.key],
255
      status: "pending",
256
      pendingCount: values[action.meta.key].pendingCount + 1,
257
      error: undefined,
258
    },
259
  };
260
}
261
/**
262
 * Updates values in response to UPDATE FULFILLED action.
263
 * - Updates metadata for updated item and overwrites value with payload.
264
 * @param values
265
 * @param action
266
 */
267
function mergeUpdateFulfill<T>(
268
  values: StateValues<T>,
269
  action: UpdateFulfillAction<T>,
270
): StateValues<T> {
271
  if (!hasKey(values, action.meta.key)) {
272
    // Even though it didn't exist in local state yet, if the server says it exists, it exists.
273
    return {
274
      ...values,
275
      [action.meta.key]: {
276
        value: action.payload,
277
        status: "fulfilled",
278
        pendingCount: 0,
279
        error: undefined,
280
      },
281
    };
282
  }
283
  return {
284
    ...values,
285
    [action.meta.key]: {
286
      value: action.payload,
287
      status:
288
        values[action.meta.key].pendingCount <= 1 ? "fulfilled" : "pending",
289
      pendingCount: decrement(values[action.meta.key].pendingCount),
290
      error: undefined,
291
    },
292
  };
293
}
294
/**
295
 * Updates values in response to UPDATE REJECTED action.
296
 * - DOES NOT throw error if item does exist, unlike other update mergeUpdate functions.
297
 *   UPDATE REJECTED action already represents a graceful response to an error.
298
 *   There is no relevant metadata to update, and nowhere to store the error, so return state as is.
299
 * - Otherwise updates metadata for item and overwrites error with payload.f
300
 * @param values
301
 * @param action
302
 */
303
function mergeUpdateReject<T>(
304
  values: StateValues<T>,
305
  action: UpdateRejectAction<T>,
306
): StateValues<T> {
307
  if (!hasKey(values, action.meta.key)) {
308
    return values;
309
  }
310
  return {
311
    ...values,
312
    [action.meta.key]: {
313
      ...values[action.meta.key],
314
      status:
315
        values[action.meta.key].pendingCount <= 1 ? "rejected" : "pending",
316
      pendingCount: decrement(values[action.meta.key].pendingCount),
317
      error: action.payload,
318
    },
319
  };
320
}
321
/**
322
 * Updates values in response to DELETE START action.
323
 * Updates metadata for item if it exists.
324
 *
325
 * Does not throw an error if item does not exist, as there are plausible scenarios (eg multiple queued DELETE requests) that could cause this.
326
 * @param values
327
 * @param action
328
 */
329
function mergeDeleteStart<T>(
330
  values: StateValues<T>,
331
  action: DeleteStartAction,
332
): StateValues<T> {
333
  if (!hasKey(values, action.meta.key)) {
334
    return values;
335
  }
336
  return {
337
    ...values,
338
    [action.meta.key]: {
339
      ...values[action.meta.key],
340
      status: "pending",
341
      pendingCount: values[action.meta.key].pendingCount + 1,
342
      error: undefined,
343
    },
344
  };
345
}
346
/**
347
 * Updates values in response to DELETE FULFILLED action.
348
 * Deletes the entire value entry, metadata included. (No effect if entry already doesn't exist.)
349
 *
350
 * Note: We can safely delete the metadata because any subsequent DELETE or UPDATE requests
351
 *   on the same item will presumably be REJECTED by the REST api.
352
 *   DELETE REJECTED and UPDATE REJECTED actions are gracefully handled by the reducer,
353
 *   even when no metadata is present.
354
 * @param values
355
 * @param action
356
 */
357
function mergeDeleteFulfill<T>(
358
  values: StateValues<T>,
359
  action: DeleteFulfillAction,
360
): StateValues<T> {
361
  return deleteProperty(values, action.meta.key);
362
}
363
364
/**
365
 * Updates values in response to DELETE REJECTED action.
366
 * Updates metadata for item if it exists.
367
 *
368
 * Does not throw an error if item does not exist, as there are plausible scenarios (eg multiple queued DELETE requests) that could cause this.
369
 * @param values
370
 * @param action
371
 */
372
function mergeDeleteReject<T>(
373
  values: StateValues<T>,
374
  action: DeleteRejectAction,
375
): StateValues<T> {
376
  if (!hasKey(values, action.meta.key)) {
377
    return values;
378
  }
379
  return {
380
    ...values,
381
    [action.meta.key]: {
382
      ...values[action.meta.key],
383
      status:
384
        values[action.meta.key].pendingCount <= 1 ? "rejected" : "pending",
385
      pendingCount: decrement(values[action.meta.key].pendingCount),
386
      error: action.payload,
387
    },
388
  };
389
}
390
391
/**
392
 * This Reducer manages the lifecycle of several http requests related to a single type of resource.
393
 * It helps keep a local version of a list of entities in sync with a REST server.
394
 *
395
 * There are 4 types of request:
396
 *   - INDEX requests fetch a list of items from the server.
397
 *   - CREATE requests create add a new item to the list.
398
 *   - UPDATE requests modify a single existing item in the list.
399
 *   - DELETE requests remove a single existing item from the list.
400
 * Every request has a lifecycle reflected by 3 possible states, resulting in a total of 12 possible reducer Actions.
401
 *   - START: every request begins with a START action.
402
 *   - FULFILLED: a successful request dispatches a FULFILLED action, with the response as its payload.
403
 *   - REJECTED: a request that fails for any reason dispatches a REJECTED action, with the Error as its payload.
404
 * Any data sent with the requests is included in the actions (in all three states) as metadata.
405
 *
406
 * The Reducer's State contains:
407
 *   - values: a map of items and associated request metadata (specifically UPDATE and DELETE request metadata)
408
 *   - indexMeta: metadata associated with INDEX requests, as they don't relate to specific items
409
 *   - createMeta: metadata associated with CREATE requests, as they don't relate to existing items
410
 *
411
 * The metadata associated with a request includes:
412
 *   - status: one of four values:
413
 *     - "initial" if a request has never been made
414
 *     - "pending" if ANY request is in progress which could modify this resource
415
 *     - "fulfilled" if the last completed request succeeded and no other request is in progress
416
 *     - "rejected" if the last completed request failed and no other request is in progress
417
 *   - pendingCount: stores the number of requests in progress. This helps account for the possibility of multiple requests being started in succession, and means one request could finish and the resource still be considered "pending".
418
 *   - error: stores the last error received from a REJECTED action. Overwritten with undefined if a later request is STARTed or FULFILLED.
419
 *
420
 * Notes about item values:
421
 *   - Its possible to include items in the initial state and then not begin any requests, in which case there will be existing values with the "initial" status.
422
 *   - REJECTED actions do not overwrite the value. Therefore when a request fails and status becomes "rejected", the last good value is still available (though it may become out-of-sync with the REST api).
423
 * @param state
424
 * @param action
425
 */
426
export function reducer<T>(
427
  state: ResourceState<T>,
428
  action: AsyncAction<T>,
429
): ResourceState<T> {
430
  switch (action.type) {
431
    case ActionTypes.IndexStart:
432
      return {
433
        ...state,
434
        indexMeta: {
435
          ...state.indexMeta,
436
          status: "pending",
437
          pendingCount: state.indexMeta.pendingCount + 1,
438
          error: undefined,
439
        },
440
      };
441
    case ActionTypes.IndexFulfill:
442
      return {
443
        ...state,
444
        indexMeta: {
445
          ...state.indexMeta,
446
          status: state.indexMeta.pendingCount <= 1 ? "fulfilled" : "pending",
447
          pendingCount: decrement(state.indexMeta.pendingCount),
448
          error: undefined,
449
          initialRefreshFinished: true,
450
        },
451
        values: mergeIndexPayload(state.values, action.payload),
452
      };
453
    case ActionTypes.IndexReject:
454
      return {
455
        ...state,
456
        indexMeta: {
457
          ...state.indexMeta,
458
          status: state.indexMeta.pendingCount <= 1 ? "rejected" : "pending",
459
          pendingCount: decrement(state.indexMeta.pendingCount),
460
          error: action.payload,
461
          initialRefreshFinished: true,
462
        },
463
      };
464
    case ActionTypes.CreateStart:
465
      // TODO: We could add an optimistic update here.
466
      return {
467
        ...state,
468
        createMeta: {
469
          ...state.createMeta,
470
          status: "pending",
471
          pendingCount: state.createMeta.pendingCount + 1,
472
          error: undefined,
473
        },
474
      };
475
    case ActionTypes.CreateFulfill:
476
      return {
477
        ...state,
478
        createMeta: {
479
          status: state.createMeta.pendingCount <= 1 ? "fulfilled" : "pending",
480
          pendingCount: decrement(state.createMeta.pendingCount),
481
          error: undefined,
482
        },
483
        values: mergeCreatePayload(state.values, action.payload),
484
      };
485
    case ActionTypes.CreateReject:
486
      return {
487
        ...state,
488
        createMeta: {
489
          status: state.createMeta.pendingCount <= 1 ? "rejected" : "pending",
490
          pendingCount: decrement(state.createMeta.pendingCount),
491
          error: action.payload,
492
        },
493
      };
494
    case ActionTypes.UpdateStart:
495
      return {
496
        ...state,
497
        values: mergeUpdateStart(state.values, action),
498
      };
499
    case ActionTypes.UpdateFulfill:
500
      return {
501
        ...state,
502
        values: mergeUpdateFulfill(state.values, action),
503
      };
504
    case ActionTypes.UpdateReject:
505
      return {
506
        ...state,
507
        values: mergeUpdateReject(state.values, action),
508
      };
509
    case ActionTypes.DeleteStart:
510
      return {
511
        ...state,
512
        values: mergeDeleteStart(state.values, action),
513
      };
514
    case ActionTypes.DeleteFulfill:
515
      return {
516
        ...state,
517
        values: mergeDeleteFulfill(state.values, action),
518
      };
519
    case ActionTypes.DeleteReject:
520
      return {
521
        ...state,
522
        values: mergeDeleteReject(state.values, action),
523
      };
524
525
    default:
526
      return state;
527
  }
528
}
529
530
export default reducer;
531