Passed
Push — feature/data-request-hooks ( b81437...8646f4 )
by Tristan
06:15
created

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

Complexity

Total Complexity 29
Complexity/F 2.42

Size

Lines of Code 519
Function Count 12

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
wmc 29
eloc 333
c 0
b 0
f 0
dl 0
loc 519
rs 10
mnd 17
bc 17
fnc 12
bpm 1.4166
cpm 2.4166
noi 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
 * - 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:
305
        values[action.meta.id].pendingCount === 1 ? "rejected" : "pending",
306
      pendingCount: decrement(values[action.meta.id].pendingCount),
307
      error: action.payload,
308
    },
309
  };
310
}
311
/**
312
 * Updates values in response to DELETE START action.
313
 * Updates metadata for item if it exists.
314
 *
315
 * Does not throw an error if item does not exist, as there are plausible scenarios (eg mupliple queued DELETE requests) that could cause this.
316
 * @param values
317
 * @param action
318
 */
319
function mergeDeleteStart<T extends { id: number }>(
320
  values: StateValues<T>,
321
  action: DeleteStartAction,
322
): StateValues<T> {
323
  if (!hasKey(values, action.meta.id)) {
324
    return values;
325
  }
326
  return {
327
    ...values,
328
    [action.meta.id]: {
329
      ...values[action.meta.id],
330
      status: "pending",
331
      pendingCount: values[action.meta.id].pendingCount + 1,
332
      error: undefined,
333
    },
334
  };
335
}
336
/**
337
 * Updates values in response to DELETE FULFILLED action.
338
 * Deletes the entire value entry, metadata included. (No effect if entry already doesn't exist.)
339
 *
340
 * Note: We can safely delete the metadata because any subsequent DELETE or UPDATE requests
341
 *   on the same item will presumably be REJECTED by the REST api.
342
 *   DELETE REJECTED and UPDATE REJECTED actions are gracefully handled by the reducer,
343
 *   even when no metadata is present.
344
 * @param values
345
 * @param action
346
 */
347
function mergeDeleteFulfill<T extends { id: number }>(
348
  values: StateValues<T>,
349
  action: DeleteFulfillAction,
350
): StateValues<T> {
351
  return deleteProperty(values, action.meta.id);
352
}
353
354
/**
355
 * Updates values in response to DELETE REJECTED action.
356
 * Updates metadata for item if it exists.
357
 *
358
 * Does not throw an error if item does not exist, as there are plausible scenarios (eg mupliple queued DELETE requests) that could cause this.
359
 * @param values
360
 * @param action
361
 */
362
function mergeDeleteReject<T extends { id: number }>(
363
  values: StateValues<T>,
364
  action: DeleteRejectAction,
365
): StateValues<T> {
366
  if (!hasKey(values, action.meta.id)) {
367
    return values;
368
  }
369
  return {
370
    ...values,
371
    [action.meta.id]: {
372
      ...values[action.meta.id],
373
      status:
374
        values[action.meta.id].pendingCount === 1 ? "rejected" : "pending",
375
      pendingCount: decrement(values[action.meta.id].pendingCount),
376
      error: action.payload,
377
    },
378
  };
379
}
380
381
/**
382
 * This Reducer manages the lifecycle of several http requests related to a single type of resource.
383
 * It helps keep a local version of a list of entities in sync with a REST server.
384
 *
385
 * There are 4 types of request:
386
 *   - INDEX requests fetch a list of items from the server.
387
 *   - CREATE requests create add a new item to the list.
388
 *   - UPDATE requests modify a single existing item in the list.
389
 *   - DELETE requests remove a single existing item from the list.
390
 * Every request has a lifecycle reflected by 3 possible states, resulting in a total of 12 possible reducer Actions.
391
 *   - START: every request begins with a START action.
392
 *   - FULFILLED: a successful request dispatches a FULFILLED action, with the response as its payload.
393
 *   - REJECTED: a request that fails for any reason dispatches a REJECTED action, with the Error as its payload.
394
 * Any data sent with the requests is included in the actions (in all three states) as metadata.
395
 *
396
 * The Reducer's State contains:
397
 *   - values: a map of items and associated request metadata (specifically UPDATE and DELETE request metadata)
398
 *   - indexMeta: metadata associated with INDEX requests, as they don't relate to specific items
399
 *   - createMeta: metadata associated with CREATE requests, as they don't relate to existing items
400
 *
401
 * The metadata associated with a request includes:
402
 *   - status: one of four values:
403
 *     - "initial" if a request has never been made
404
 *     - "pending" if ANY request is in progress which could modify this resource
405
 *     - "fulfilled" if the last completed request succeeded and no other request is in progress
406
 *     - "rejected" if the last completed request failed and no other request is in progress
407
 *   - 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".
408
 *   - error: stores the last error recieved from a REJECTED action. Overwritten with undefined if a later request is STARTed or FULFILLED.
409
 *
410
 * Notes about item values:
411
 *   - 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.
412
 *   - 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).
413
 * @param state
414
 * @param action
415
 */
416
export function reducer<T extends { id: number }>(
417
  state: ResourceState<T>,
418
  action: AsyncAction<T>,
419
): ResourceState<T> {
420
  switch (action.type) {
421
    case ActionTypes.IndexStart:
422
      return {
423
        ...state,
424
        indexMeta: {
425
          ...state.indexMeta,
426
          status: "pending",
427
          pendingCount: state.indexMeta.pendingCount + 1,
428
          error: undefined,
429
        },
430
      };
431
    case ActionTypes.IndexFulfill:
432
      return {
433
        ...state,
434
        indexMeta: {
435
          ...state.indexMeta,
436
          status: state.indexMeta.pendingCount === 1 ? "fulfilled" : "pending",
437
          pendingCount: decrement(state.indexMeta.pendingCount),
438
          error: undefined,
439
        },
440
        values: mergeIndexPayload(state.values, action.payload),
441
      };
442
    case ActionTypes.IndexReject:
443
      return {
444
        ...state,
445
        indexMeta: {
446
          ...state.indexMeta,
447
          status: state.indexMeta.pendingCount === 1 ? "rejected" : "pending",
448
          pendingCount: decrement(state.indexMeta.pendingCount),
449
          error: action.payload,
450
        },
451
      };
452
    case ActionTypes.CreateStart:
453
      // TODO: We could add an optimistic update here.
454
      return {
455
        ...state,
456
        createMeta: {
457
          ...state.createMeta,
458
          status: "pending",
459
          pendingCount: state.createMeta.pendingCount + 1,
460
          error: undefined,
461
        },
462
      };
463
    case ActionTypes.CreateFulfill:
464
      return {
465
        ...state,
466
        createMeta: {
467
          status: state.createMeta.pendingCount === 1 ? "fulfilled" : "pending",
468
          pendingCount: decrement(state.createMeta.pendingCount),
469
          error: undefined,
470
        },
471
        values: mergeCreatePayload(state.values, action.payload),
472
      };
473
    case ActionTypes.CreateReject:
474
      return {
475
        ...state,
476
        createMeta: {
477
          status: state.createMeta.pendingCount === 1 ? "rejected" : "pending",
478
          pendingCount: decrement(state.createMeta.pendingCount),
479
          error: action.payload,
480
        },
481
      };
482
    case ActionTypes.UpdateStart:
483
      return {
484
        ...state,
485
        values: mergeUpdateStart(state.values, action),
486
      };
487
    case ActionTypes.UpdateFulfill:
488
      return {
489
        ...state,
490
        values: mergeUpdateFulfill(state.values, action),
491
      };
492
    case ActionTypes.UpdateReject:
493
      return {
494
        ...state,
495
        values: mergeUpdateReject(state.values, action),
496
      };
497
    case ActionTypes.DeleteStart:
498
      return {
499
        ...state,
500
        values: mergeDeleteStart(state.values, action),
501
      };
502
    case ActionTypes.deleteFulfill:
503
      return {
504
        ...state,
505
        values: mergeDeleteFulfill(state.values, action),
506
      };
507
    case ActionTypes.DeleteReject:
508
      return {
509
        ...state,
510
        values: mergeDeleteReject(state.values, action),
511
      };
512
513
    default:
514
      return state;
515
  }
516
}
517
518
export default reducer;
519