Passed
Push — feature/data-request-hooks ( 8646f4...d9a1fc )
by Tristan
05:36
created

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

Complexity

Total Complexity 29
Complexity/F 2.42

Size

Lines of Code 517
Function Count 12

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
wmc 29
eloc 331
mnd 17
bc 17
fnc 12
dl 0
loc 517
bpm 1.4166
cpm 2.4166
noi 0
c 0
b 0
f 0
rs 10

12 Functions

Rating   Name   Duplication   Size   Complexity  
A indexCrudReducer.ts ➔ initializeState 0 20 1
A indexCrudReducer.ts ➔ decrement 0 8 2
A indexCrudReducer.ts ➔ mergeUpdateFulfill 0 24 3
A indexCrudReducer.ts ➔ mergeIndexItem 0 25 3
A indexCrudReducer.ts ➔ mergeCreatePayload 0 26 2
A indexCrudReducer.ts ➔ mergeUpdateStart 0 26 2
A indexCrudReducer.ts ➔ mergeIndexPayload 0 19 1
C indexCrudReducer.ts ➔ reducer 0 135 6
A indexCrudReducer.ts ➔ mergeUpdateReject 0 23 3
A indexCrudReducer.ts ➔ mergeDeleteFulfill 0 16 1
A indexCrudReducer.ts ➔ mergeDeleteStart 0 22 2
A indexCrudReducer.ts ➔ mergeDeleteReject 0 23 3
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
 * - Throws an error if the newly created item has the same id as an existing item, since we cannot know which version to keep.
203
 *   This error should never happen during normal interaction with a REST api.
204
 * - Otherwise adds the new item to values, with "fulfilled" status.
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
    // Something has gone wrong if an existing item has the same id as the newly created one.
214
    throw new Error(
215
      "Cannot create new item as an existing item shares the same id. Try refreshing the whole index.",
216
    );
217
  }
218
  return {
219
    ...values,
220
    [payload.id]: {
221
      value: payload,
222
      error: undefined,
223
      status: "fulfilled",
224
      pendingCount: 0,
225
    },
226
  };
227
}
228
229
/**
230
 * Updates values in response to UPDATE START action.
231
 * - Throws error if item being updated does not exist.
232
 * - Otherwise updates metadata for updated item
233
 * @param values
234
 * @param action
235
 */
236
function mergeUpdateStart<T extends { id: number }>(
237
  values: StateValues<T>,
238
  action: UpdateStartAction<T>,
239
): StateValues<T> {
240
  if (!hasKey(values, action.meta.id)) {
241
    throw new Error(
242
      "Cannot update an item that doesn't exist yet. Maybe you tried to update an item after deleting it?",
243
    );
244
  }
245
  return {
246
    ...values,
247
    [action.meta.id]: {
248
      // TODO: if we wanted to do an optimistic update, we could save action.payload.item here.
249
      // But we would need some way to reverse it if it failed.
250
      ...values[action.meta.id],
251
      status: "pending",
252
      pendingCount: values[action.meta.id].pendingCount + 1,
253
      error: undefined,
254
    },
255
  };
256
}
257
/**
258
 * Updates values in response to UPDATE FULFILLED action.
259
 * - Throws error if item being updated does not exist.
260
 * - Otherwise updates metadata for updated item and overwrites value with payload.
261
 * @param values
262
 * @param action
263
 */
264
function mergeUpdateFulfill<T extends { id: number }>(
265
  values: StateValues<T>,
266
  action: UpdateFulfillAction<T>,
267
): StateValues<T> {
268
  if (!hasKey(values, action.meta.id)) {
269
    throw new Error(
270
      "Cannot update an item that doesn't exist yet. Maybe you tried to update an item after deleting it?",
271
    );
272
  }
273
  return {
274
    ...values,
275
    [action.meta.id]: {
276
      value: action.payload,
277
      status:
278
        values[action.meta.id].pendingCount <= 1 ? "fulfilled" : "pending",
279
      pendingCount: decrement(values[action.meta.id].pendingCount),
280
      error: undefined,
281
    },
282
  };
283
}
284
/**
285
 * Updates values in response to UPDATE REJECTED action.
286
 * - DOES NOT throw error if item does exist, unlike other update mergeUpdate functions.
287
 *   UPDATE REJECTED action already represents a graceful response to an error.
288
 *   There is no relevant metadata to update, and nowhere to store the error, so return state as is.
289
 * - Otherwise updates metdata for item and overwrites error with payload.f
290
 * @param values
291
 * @param action
292
 */
293
function mergeUpdateReject<T extends { id: number }>(
294
  values: StateValues<T>,
295
  action: UpdateRejectAction<T>,
296
): StateValues<T> {
297
  if (!hasKey(values, action.meta.id)) {
298
    return values;
299
  }
300
  return {
301
    ...values,
302
    [action.meta.id]: {
303
      ...values[action.meta.id],
304
      status: values[action.meta.id].pendingCount <= 1 ? "rejected" : "pending",
305
      pendingCount: decrement(values[action.meta.id].pendingCount),
306
      error: action.payload,
307
    },
308
  };
309
}
310
/**
311
 * Updates values in response to DELETE START action.
312
 * Updates metadata for item if it exists.
313
 *
314
 * Does not throw an error if item does not exist, as there are plausible scenarios (eg mupliple queued DELETE requests) that could cause this.
315
 * @param values
316
 * @param action
317
 */
318
function mergeDeleteStart<T extends { id: number }>(
319
  values: StateValues<T>,
320
  action: DeleteStartAction,
321
): StateValues<T> {
322
  if (!hasKey(values, action.meta.id)) {
323
    return values;
324
  }
325
  return {
326
    ...values,
327
    [action.meta.id]: {
328
      ...values[action.meta.id],
329
      status: "pending",
330
      pendingCount: values[action.meta.id].pendingCount + 1,
331
      error: undefined,
332
    },
333
  };
334
}
335
/**
336
 * Updates values in response to DELETE FULFILLED action.
337
 * Deletes the entire value entry, metadata included. (No effect if entry already doesn't exist.)
338
 *
339
 * Note: We can safely delete the metadata because any subsequent DELETE or UPDATE requests
340
 *   on the same item will presumably be REJECTED by the REST api.
341
 *   DELETE REJECTED and UPDATE REJECTED actions are gracefully handled by the reducer,
342
 *   even when no metadata is present.
343
 * @param values
344
 * @param action
345
 */
346
function mergeDeleteFulfill<T extends { id: number }>(
347
  values: StateValues<T>,
348
  action: DeleteFulfillAction,
349
): StateValues<T> {
350
  return deleteProperty(values, action.meta.id);
351
}
352
353
/**
354
 * Updates values in response to DELETE REJECTED action.
355
 * Updates metadata for item if it exists.
356
 *
357
 * Does not throw an error if item does not exist, as there are plausible scenarios (eg mupliple queued DELETE requests) that could cause this.
358
 * @param values
359
 * @param action
360
 */
361
function mergeDeleteReject<T extends { id: number }>(
362
  values: StateValues<T>,
363
  action: DeleteRejectAction,
364
): StateValues<T> {
365
  if (!hasKey(values, action.meta.id)) {
366
    return values;
367
  }
368
  return {
369
    ...values,
370
    [action.meta.id]: {
371
      ...values[action.meta.id],
372
      status: values[action.meta.id].pendingCount <= 1 ? "rejected" : "pending",
373
      pendingCount: decrement(values[action.meta.id].pendingCount),
374
      error: action.payload,
375
    },
376
  };
377
}
378
379
/**
380
 * This Reducer manages the lifecycle of several http requests related to a single type of resource.
381
 * It helps keep a local version of a list of entities in sync with a REST server.
382
 *
383
 * There are 4 types of request:
384
 *   - INDEX requests fetch a list of items from the server.
385
 *   - CREATE requests create add a new item to the list.
386
 *   - UPDATE requests modify a single existing item in the list.
387
 *   - DELETE requests remove a single existing item from the list.
388
 * Every request has a lifecycle reflected by 3 possible states, resulting in a total of 12 possible reducer Actions.
389
 *   - START: every request begins with a START action.
390
 *   - FULFILLED: a successful request dispatches a FULFILLED action, with the response as its payload.
391
 *   - REJECTED: a request that fails for any reason dispatches a REJECTED action, with the Error as its payload.
392
 * Any data sent with the requests is included in the actions (in all three states) as metadata.
393
 *
394
 * The Reducer's State contains:
395
 *   - values: a map of items and associated request metadata (specifically UPDATE and DELETE request metadata)
396
 *   - indexMeta: metadata associated with INDEX requests, as they don't relate to specific items
397
 *   - createMeta: metadata associated with CREATE requests, as they don't relate to existing items
398
 *
399
 * The metadata associated with a request includes:
400
 *   - status: one of four values:
401
 *     - "initial" if a request has never been made
402
 *     - "pending" if ANY request is in progress which could modify this resource
403
 *     - "fulfilled" if the last completed request succeeded and no other request is in progress
404
 *     - "rejected" if the last completed request failed and no other request is in progress
405
 *   - 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".
406
 *   - error: stores the last error recieved from a REJECTED action. Overwritten with undefined if a later request is STARTed or FULFILLED.
407
 *
408
 * Notes about item values:
409
 *   - 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.
410
 *   - 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).
411
 * @param state
412
 * @param action
413
 */
414
export function reducer<T extends { id: number }>(
415
  state: ResourceState<T>,
416
  action: AsyncAction<T>,
417
): ResourceState<T> {
418
  switch (action.type) {
419
    case ActionTypes.IndexStart:
420
      return {
421
        ...state,
422
        indexMeta: {
423
          ...state.indexMeta,
424
          status: "pending",
425
          pendingCount: state.indexMeta.pendingCount + 1,
426
          error: undefined,
427
        },
428
      };
429
    case ActionTypes.IndexFulfill:
430
      return {
431
        ...state,
432
        indexMeta: {
433
          ...state.indexMeta,
434
          status: state.indexMeta.pendingCount <= 1 ? "fulfilled" : "pending",
435
          pendingCount: decrement(state.indexMeta.pendingCount),
436
          error: undefined,
437
        },
438
        values: mergeIndexPayload(state.values, action.payload),
439
      };
440
    case ActionTypes.IndexReject:
441
      return {
442
        ...state,
443
        indexMeta: {
444
          ...state.indexMeta,
445
          status: state.indexMeta.pendingCount <= 1 ? "rejected" : "pending",
446
          pendingCount: decrement(state.indexMeta.pendingCount),
447
          error: action.payload,
448
        },
449
      };
450
    case ActionTypes.CreateStart:
451
      // TODO: We could add an optimistic update here.
452
      return {
453
        ...state,
454
        createMeta: {
455
          ...state.createMeta,
456
          status: "pending",
457
          pendingCount: state.createMeta.pendingCount + 1,
458
          error: undefined,
459
        },
460
      };
461
    case ActionTypes.CreateFulfill:
462
      return {
463
        ...state,
464
        createMeta: {
465
          status: state.createMeta.pendingCount <= 1 ? "fulfilled" : "pending",
466
          pendingCount: decrement(state.createMeta.pendingCount),
467
          error: undefined,
468
        },
469
        values: mergeCreatePayload(state.values, action.payload),
470
      };
471
    case ActionTypes.CreateReject:
472
      return {
473
        ...state,
474
        createMeta: {
475
          status: state.createMeta.pendingCount <= 1 ? "rejected" : "pending",
476
          pendingCount: decrement(state.createMeta.pendingCount),
477
          error: action.payload,
478
        },
479
      };
480
    case ActionTypes.UpdateStart:
481
      return {
482
        ...state,
483
        values: mergeUpdateStart(state.values, action),
484
      };
485
    case ActionTypes.UpdateFulfill:
486
      return {
487
        ...state,
488
        values: mergeUpdateFulfill(state.values, action),
489
      };
490
    case ActionTypes.UpdateReject:
491
      return {
492
        ...state,
493
        values: mergeUpdateReject(state.values, action),
494
      };
495
    case ActionTypes.DeleteStart:
496
      return {
497
        ...state,
498
        values: mergeDeleteStart(state.values, action),
499
      };
500
    case ActionTypes.deleteFulfill:
501
      return {
502
        ...state,
503
        values: mergeDeleteFulfill(state.values, action),
504
      };
505
    case ActionTypes.DeleteReject:
506
      return {
507
        ...state,
508
        values: mergeDeleteReject(state.values, action),
509
      };
510
511
    default:
512
      return state;
513
  }
514
}
515
516
export default reducer;
517