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

resources/assets/js/hooks/webResourceHooks/indexResourceHook.ts   B

Complexity

Total Complexity 47
Complexity/F 5.88

Size

Lines of Code 346
Function Count 8

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
wmc 47
eloc 239
mnd 39
bc 39
fnc 8
dl 0
loc 346
rs 8.64
bpm 4.875
cpm 5.875
noi 0
c 0
b 0
f 0

8 Functions

Rating   Name   Duplication   Size   Complexity  
A indexResourceHook.ts ➔ doNothing 0 2 1
A indexResourceHook.ts ➔ statusSelector 0 11 2
A indexResourceHook.ts ➔ defaultCreateEndpoint 0 3 1
A indexResourceHook.ts ➔ valuesSelector 0 10 1
A indexResourceHook.ts ➔ defaultEntityEndpoint 0 6 1
A indexResourceHook.ts ➔ isValidEntityList 0 3 1
A indexResourceHook.ts ➔ isValidEntity 0 4 1
F indexResourceHook.ts ➔ useResourceIndex 0 261 39

How to fix   Complexity   

Complexity

Complex classes like resources/assets/js/hooks/webResourceHooks/indexResourceHook.ts often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
import {
2
  Reducer,
3
  useCallback,
4
  useEffect,
5
  useMemo,
6
  useReducer,
7
  useRef,
8
} from "react";
9
import {
10
  deleteRequest,
11
  FetchError,
12
  getRequest,
13
  postRequest,
14
  processJsonResponse,
15
  putRequest,
16
} from "../../helpers/httpRequests";
17
import { hasKey, identity } from "../../helpers/queries";
18
import indexCrudReducer, {
19
  initializeState,
20
  ResourceState,
21
  ActionTypes,
22
  AsyncAction,
23
} from "./indexCrudReducer";
24
import { Json, ResourceStatus } from "./types";
25
26
type IndexedObject<T> = {
27
  [id: number]: T;
28
};
29
30
function valuesSelector<T extends { id: number }>(
31
  state: ResourceState<T>,
32
): IndexedObject<T> {
33
  return Object.values(state.values).reduce(
34
    (collection: IndexedObject<T>, item) => {
35
      collection[item.value.id] = item.value;
36
      return collection;
37
    },
38
    {},
39
  );
40
}
41
function statusSelector<T extends { id: number }>(
42
  state: ResourceState<T>,
43
): IndexedObject<ResourceStatus> {
44
  // If the entire index is being refreshed, then each individual item should be considered "pending".
45
  const forcePending = state.indexMeta.status === "pending";
46
  return Object.values(state.values).reduce(
47
    (collection: IndexedObject<ResourceStatus>, item) => {
48
      collection[item.value.id] = forcePending ? "pending" : item.status;
49
      return collection;
50
    },
51
    {},
52
  );
53
}
54
55
// Defining these functions outside of the hook, despite their simplicity,
56
// so they remain constant between re-renders.
57
58
function defaultEntityEndpoint(baseEndpoint: string, id: number): string {
59
  return `${baseEndpoint}/${id}`;
60
}
61
62
function defaultCreateEndpoint(baseEndpoint: string): string {
63
  return baseEndpoint;
64
}
65
66
function doNothing(): void {
67
  /* do nothing */
68
}
69
70
// The value dispatched to the reducer must have an id, or the reducer cannot place it correctly.
71
function isValidEntity(value: any): boolean {
72
  return hasKey(value, "id");
73
}
74
75
function isValidEntityList(value: any): boolean {
76
  return Array.isArray(value) && value.every(isValidEntity);
77
}
78
79
export const UNEXPECTED_FORMAT_ERROR =
80
  "Response from server was not expected format";
81
82
/**
83
 * This hook keeps a local list of entities in sync with a REST api representing a single resource.
84
 *
85
 * *** Interaction with API ***
86
 * The API can be interacted with in 4 ways:
87
 *   - Refreshing, getting a complete list of entities
88
 *   - Creating a new entity that is added to the list
89
 *   - Updating a specific entity in the list
90
 *   - Deleting a specific entity in the list
91
 *
92
 * The only required argument is the API endpoint. By default, the CRUD operations (Create, Read, Update, Delete) follow normal REST conventions:
93
 *   - Create submits a POST request to endpoint
94
 *   - Refresh submits a GET request to endpoint
95
 *   - Update submits a PUT request to endpoint/id
96
 *   - Delete submits a DELETE request to endpoint/id
97
 * The urls used may be modified by overriding resolveEntityEndpoint (for update and delete requests) and resolveCreateEndpoint.
98
 * This may allow, for example, for using query parameters in some of these urls.
99
 * Note: The HTTP verbs used cannot be changed.
100
 *
101
 * The api requests MUST return valid JSON (except for delete requests), or the request will be considered to have failed.
102
 * The response to refresh requests must be a JSON List, and update and create requests must return the resulting entity as a json object.
103
 * However, the JSON objects may be preprocessed before being stored localy, by overriding parseEntityResponse and/or parseIndexResponse.
104
 *
105
 * *** Hook Values and Statuses *** *
106
 * values: This represents the list of entities compresing the resource.
107
 *   Note: values is not an array. It is an object with each entity indexed by id, so specific items may be retrieved more easily.
108
 *   Note: By default, values starts out empty and the hook immediately triggers a refresh callback.
109
 *   Note: An initialValue may be provided, which supresses that initial refresh, unless forceInitialRefresh is ALSO overridden with true.
110
 *   Note: Setting forceInitialRefresh to false has no effect. Set initialValue to [] instead.
111
 *
112
 * indexStatus, createStatus, and entityStatus: These tell you whether any requests are currently in progress, and if not whether the last completed request was successful.
113
 *   Note: entityStatus covers both update and delete requests, and contains a status value for each entity, indexed by id.
114
 *
115
 * *** Callbacks *** *
116
 * create, refresh, update, deleteResource: These callbacks trigger requests to the api, updating values and the status properties accordingly.
117
 *   Note: while conventional "reactive" programming would control UI based only on the current values and status properties, these callbacks also return promises
118
 *         which allow code to respond to the success or failure of specific requests.
119
 *
120
 * *** Error Handling ***
121
 * You may watch for statuses of "rejected" to determing whether an error occured during certain reqeusts.
122
 * To respond to any details of potential errors, override handleError.
123
 * Note: If the error was caused by a non-200 response from the server, handleError will recieve an instance of FetchError, which contains the entire response object.
124
 */
125
export function useResourceIndex<T extends { id: number }>(
126
  endpoint: string, // API endpoint that returns a list of T.
127
  overrides?: {
128
    initialValue?: T[]; // Defaults to an empty list. If this is overriden, initial fetch is skipped (unless forceInitialRefresh is set to true).
129
    forceInitialRefresh?: boolean; // If you set an initialValue but also want to refresh immediately, set this to true.
130
    parseEntityResponse?: (response: Json) => T; // Defaults to the identity function.
131
    parseIndexResponse?: (response: Json) => T[]; // Defaults to (response) => response.map(parseEntityResponse)
132
    resolveEntityEndpoint?: (baseEndpoint: string, id: number) => string; // Defaults to appending '/id' to baseEndpoint. Used for update (PUT) and delete (DELETE) requests.
133
    resolveCreateEndpoint?: (baseEndpoint: string, newEntity: T) => string; // Defaults to identical to endpoint. Used for create (POST) requests.
134
    handleError?: (error: Error | FetchError) => void;
135
  },
136
): {
137
  values: IndexedObject<T>;
138
  indexStatus: ResourceStatus; // The state of any requests to reload the entire index.
139
  createStatus: ResourceStatus; // If ANY create requests are in progress, this is 'pending'. Otherwise, it is 'fulfilled' or 'rejected' depending on the last request to complete.
140
  entityStatus: IndexedObject<ResourceStatus>; // Note that if indexStatus is 'pending', every entity status will also be 'pending'.
141
  create: (newValue: T) => Promise<T>;
142
  refresh: () => Promise<T[]>; // Reloads the entire index.
143
  update: (newValue: T) => Promise<T>;
144
  deleteResource: (id: number) => Promise<void>;
145
} {
146
  const initialValue = overrides?.initialValue ?? [];
147
  const doInitialRefresh =
148
    overrides?.initialValue === undefined ||
149
    overrides?.forceInitialRefresh === true;
150
  const parseEntityResponse = overrides?.parseEntityResponse ?? identity;
151
  const parseIndexResponse = useMemo(
152
    () =>
153
      overrides?.parseIndexResponse ??
154
      ((response: Json): T[] => response.map(parseEntityResponse)),
155
    [overrides?.parseIndexResponse, parseEntityResponse],
156
  );
157
  const resolveEntityEndpoint =
158
    overrides?.resolveEntityEndpoint ?? defaultEntityEndpoint;
159
  const resolveCreateEndpoint =
160
    overrides?.resolveCreateEndpoint ?? defaultCreateEndpoint;
161
  const handleError = overrides?.handleError ?? doNothing;
162
163
  const isSubscribed = useRef(true);
164
165
  const [state, dispatch] = useReducer<
166
    Reducer<ResourceState<T>, AsyncAction<T>>,
167
    T[] // This represent type of initialValue, passed to initializeState to create initial state.
168
  >(indexCrudReducer, initialValue, initializeState);
169
170
  const values = useMemo(() => valuesSelector(state), [state]);
171
  const indexStatus = state.indexMeta.status;
172
  const createStatus = state.createMeta.status;
173
  const entityStatus = useMemo(() => statusSelector(state), [state]);
174
175
  const create = useCallback(
176
    async (newValue: T): Promise<T> => {
177
      dispatch({
178
        type: ActionTypes.CreateStart,
179
        meta: { item: newValue },
180
      });
181
      let entity: T;
182
      try {
183
        const json = await postRequest(
184
          resolveCreateEndpoint(endpoint, newValue),
185
          newValue,
186
        ).then(processJsonResponse);
187
        entity = parseEntityResponse(json);
188
        if (!isValidEntity(entity)) {
189
          throw new Error(UNEXPECTED_FORMAT_ERROR);
190
        }
191
      } catch (error) {
192
        if (isSubscribed.current) {
193
          dispatch({
194
            type: ActionTypes.CreateReject,
195
            payload: error,
196
            meta: { item: newValue },
197
          });
198
          handleError(error);
199
        }
200
        throw error;
201
      }
202
      if (isSubscribed.current) {
203
        dispatch({
204
          type: ActionTypes.CreateFulfill,
205
          payload: entity,
206
          meta: { item: newValue },
207
        });
208
      }
209
      return entity;
210
    },
211
    [endpoint, resolveCreateEndpoint, parseEntityResponse, handleError],
212
  );
213
214
  const refresh = useCallback(async (): Promise<T[]> => {
215
    dispatch({
216
      type: ActionTypes.IndexStart,
217
    });
218
    let index: T[];
219
    try {
220
      const json = await getRequest(endpoint).then(processJsonResponse);
221
      index = parseIndexResponse(json);
222
      if (!isValidEntityList(index)) {
223
        throw new Error(UNEXPECTED_FORMAT_ERROR);
224
      }
225
    } catch (error) {
226
      if (isSubscribed.current) {
227
        dispatch({
228
          type: ActionTypes.IndexReject,
229
          payload: error,
230
        });
231
        handleError(error);
232
      }
233
      throw error;
234
    }
235
    if (isSubscribed.current) {
236
      dispatch({
237
        type: ActionTypes.IndexFulfill,
238
        payload: index,
239
      });
240
    }
241
    return index;
242
  }, [endpoint, parseIndexResponse, handleError]);
243
244
  const update = useCallback(
245
    async (newValue: T): Promise<T> => {
246
      const meta = { id: newValue.id, item: newValue };
247
      dispatch({
248
        type: ActionTypes.UpdateStart,
249
        meta,
250
      });
251
      let value: T;
252
      try {
253
        const json = await putRequest(
254
          resolveEntityEndpoint(endpoint, newValue.id),
255
          newValue,
256
        ).then(processJsonResponse);
257
        value = parseEntityResponse(json);
258
        if (!isValidEntity(value)) {
259
          throw new Error(UNEXPECTED_FORMAT_ERROR);
260
        }
261
      } catch (error) {
262
        if (isSubscribed.current) {
263
          dispatch({
264
            type: ActionTypes.UpdateReject,
265
            payload: error,
266
            meta,
267
          });
268
          handleError(error);
269
        }
270
        throw error;
271
      }
272
      if (isSubscribed.current) {
273
        dispatch({
274
          type: ActionTypes.UpdateFulfill,
275
          payload: value,
276
          meta,
277
        });
278
      }
279
      return value;
280
    },
281
    [endpoint, resolveEntityEndpoint, parseEntityResponse, handleError],
282
  );
283
284
  const deleteResource = useCallback(
285
    async (id: number): Promise<void> => {
286
      dispatch({
287
        type: ActionTypes.DeleteStart,
288
        meta: { id },
289
      });
290
      try {
291
        const response = await deleteRequest(
292
          resolveEntityEndpoint(endpoint, id),
293
        );
294
        if (!response.ok) {
295
          throw new FetchError(response);
296
        }
297
      } catch (error) {
298
        if (isSubscribed.current) {
299
          dispatch({
300
            type: ActionTypes.DeleteReject,
301
            payload: error,
302
            meta: { id },
303
          });
304
          handleError(error);
305
        }
306
        throw error;
307
      }
308
      if (isSubscribed.current) {
309
        dispatch({
310
          type: ActionTypes.DeleteFulfill,
311
          meta: { id },
312
        });
313
      }
314
    },
315
    [endpoint, resolveEntityEndpoint, handleError],
316
  );
317
318
  // Despite the usual guidlines, this should only be reconsidered if endpoint changes.
319
  // Changing doInitialRefresh after the first run (or refresh) should not cause this to rerun.
320
  useEffect(() => {
321
    if (doInitialRefresh) {
322
      refresh().catch(doNothing);
323
    }
324
325
    // Unsubscribe from promises when this hook is unmounted.
326
    return (): void => {
327
      isSubscribed.current = false;
328
    };
329
330
    // eslint-disable-next-line react-hooks/exhaustive-deps
331
  }, [endpoint]);
332
333
  return {
334
    values,
335
    indexStatus,
336
    createStatus,
337
    entityStatus,
338
    create,
339
    refresh,
340
    update,
341
    deleteResource,
342
  };
343
}
344
345
export default useResourceIndex;
346