Passed
Push — feature/azure-webapp-pipeline-... ( 271549...3c88ad )
by Grant
07:11 queued 10s
created

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

Complexity

Total Complexity 28
Complexity/F 2.55

Size

Lines of Code 526
Function Count 11

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

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