Passed
Push — task/explore-skills-ui ( d41a63...af1244 )
by Yonathan
06:51 queued 10s
created

indexCrudReducer.ts ➔ reducer   C

Complexity

Conditions 6

Size

Total Lines 135
Code Lines 81

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 81
dl 0
loc 135
rs 6.6884
c 0
b 0
f 0
cc 6

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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