Passed
Push — feature/data-request-hooks ( f68207...60c09e )
by Tristan
04:38
created

indexCrudReducer.ts ➔ mergeIndexItem   A

Complexity

Conditions 2

Size

Total Lines 23
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

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