Passed
Push — feature/data-request-hooks ( 7ed4d3...329ea7 )
by Tristan
05:27
created

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

Complexity

Total Complexity 30
Complexity/F 2.5

Size

Lines of Code 530
Function Count 12

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

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