Passed
Push — feature/data-request-hooks ( e74f4e...562d87 )
by Tristan
04:40
created

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

Complexity

Total Complexity 46
Complexity/F 5.75

Size

Lines of Code 296
Function Count 8

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
wmc 46
eloc 234
mnd 38
bc 38
fnc 8
dl 0
loc 296
rs 8.72
bpm 4.75
cpm 5.75
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
F indexResourceHook.ts ➔ useResourceIndex 0 211 38
A indexResourceHook.ts ➔ isValidEntityList 0 3 1
A indexResourceHook.ts ➔ isValidEntity 0 4 1

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
export function useResourceIndex<T extends { id: number }>(
83
  endpoint: string, // API endpoint that returns a list of T.
84
  overrides?: {
85
    initialValue?: T[]; // Defaults to an empty list. If this is overriden, initial fetch is skipped (unless forceInitialRefresh is set to true).
86
    forceInitialRefresh?: boolean; // If you set an initialValue but also want to refresh immediately, set this to true.
87
    parseIndexResponse?: (response: Json) => T[]; // Defaults to the identity function.
88
    parseEntityResponse?: (response: Json) => T; // Defaults to the identity function.
89
    resolveEntityEndpoint?: (baseEndpoint: string, id: number) => string; // Defaults to appending '/id' to baseEndpoint. Used for update (PUT) and delete (DELETE) requests.
90
    resolveCreateEndpoint?: (baseEndpoint: string, newEntity: T) => string; // Defaults to identical to endpoint. Used for create (POST) requests.
91
    handleError?: (error: Error | FetchError) => void;
92
  },
93
): {
94
  values: IndexedObject<T>;
95
  indexStatus: ResourceStatus; // The state of any requests to reload the entire index.
96
  entityStatus: IndexedObject<ResourceStatus>; // Note that if indexStatus is 'pending', every entity status will also be 'pending'.
97
  create: (newValue: T) => Promise<T>;
98
  refresh: () => Promise<T[]>; // Reloads the entire index.
99
  update: (newValue: T) => Promise<T>;
100
  deleteResource: (id: number) => Promise<void>;
101
} {
102
  const initialValue = overrides?.initialValue ?? [];
103
  const doInitialRefresh =
104
    overrides?.initialValue === undefined ||
105
    overrides?.forceInitialRefresh === true;
106
  const parseIndexResponse = overrides?.parseIndexResponse ?? identity;
107
  const parseEntityResponse = overrides?.parseEntityResponse ?? identity;
108
  const resolveEntityEndpoint =
109
    overrides?.resolveEntityEndpoint ?? defaultEntityEndpoint;
110
  const resolveCreateEndpoint =
111
    overrides?.resolveCreateEndpoint ?? defaultCreateEndpoint;
112
  const handleError = overrides?.handleError ?? doNothing;
113
114
  const isSubscribed = useRef(true);
115
116
  const [state, dispatch] = useReducer<
117
    Reducer<ResourceState<T>, AsyncAction<T>>,
118
    T[] // This represent type of initialValue, passed to initializeState to create initial state.
119
  >(indexCrudReducer, initialValue, initializeState);
120
121
  const values = useMemo(() => valuesSelector(state), [state]);
122
  const indexStatus = state.indexMeta.status;
123
  const entityStatus = useMemo(() => statusSelector(state), [state]);
124
125
  const create = useCallback(
126
    async (newValue: T): Promise<T> => {
127
      dispatch({
128
        type: ActionTypes.CreateStart,
129
        meta: { item: newValue },
130
      });
131
      let entity: T;
132
      try {
133
        const json = await postRequest(
134
          resolveCreateEndpoint(endpoint, newValue),
135
          newValue,
136
        ).then(processJsonResponse);
137
        entity = parseEntityResponse(json);
138
        if (!isValidEntity(entity)) {
139
          throw new Error(UNEXPECTED_FORMAT_ERROR);
140
        }
141
      } catch (error) {
142
        if (isSubscribed.current) {
143
          dispatch({
144
            type: ActionTypes.CreateReject,
145
            payload: error,
146
            meta: { item: newValue },
147
          });
148
          handleError(error);
149
        }
150
        throw error;
151
      }
152
      if (isSubscribed.current) {
153
        dispatch({
154
          type: ActionTypes.CreateFulfill,
155
          payload: entity,
156
          meta: { item: newValue },
157
        });
158
      }
159
      return entity;
160
    },
161
    [endpoint, resolveCreateEndpoint, parseEntityResponse, handleError],
162
  );
163
164
  const refresh = useCallback(async (): Promise<T[]> => {
165
    dispatch({
166
      type: ActionTypes.IndexStart,
167
    });
168
    let index: T[];
169
    try {
170
      const json = await getRequest(endpoint).then(processJsonResponse);
171
      index = parseIndexResponse(json);
172
      if (!isValidEntityList(index)) {
173
        throw new Error(UNEXPECTED_FORMAT_ERROR);
174
      }
175
    } catch (error) {
176
      if (isSubscribed.current) {
177
        dispatch({
178
          type: ActionTypes.IndexReject,
179
          payload: error,
180
        });
181
        handleError(error);
182
      }
183
      throw error;
184
    }
185
    if (isSubscribed.current) {
186
      dispatch({
187
        type: ActionTypes.IndexFulfill,
188
        payload: index,
189
      });
190
    }
191
    return index;
192
  }, [endpoint, parseIndexResponse, handleError]);
193
194
  const update = useCallback(
195
    async (newValue: T): Promise<T> => {
196
      const meta = { id: newValue.id, item: newValue };
197
      dispatch({
198
        type: ActionTypes.UpdateStart,
199
        meta,
200
      });
201
      let value: T;
202
      try {
203
        const json = await putRequest(
204
          resolveEntityEndpoint(endpoint, newValue.id),
205
          newValue,
206
        ).then(processJsonResponse);
207
        value = parseEntityResponse(json);
208
        if (!isValidEntity(value)) {
209
          throw new Error(UNEXPECTED_FORMAT_ERROR);
210
        }
211
      } catch (error) {
212
        if (isSubscribed.current) {
213
          dispatch({
214
            type: ActionTypes.UpdateReject,
215
            payload: error,
216
            meta,
217
          });
218
          handleError(error);
219
        }
220
        throw error;
221
      }
222
      if (isSubscribed.current) {
223
        dispatch({
224
          type: ActionTypes.UpdateFulfill,
225
          payload: value,
226
          meta,
227
        });
228
      }
229
      return value;
230
    },
231
    [endpoint, resolveEntityEndpoint, parseEntityResponse, handleError],
232
  );
233
234
  const deleteResource = useCallback(
235
    async (id: number): Promise<void> => {
236
      dispatch({
237
        type: ActionTypes.DeleteStart,
238
        meta: { id },
239
      });
240
      try {
241
        const response = await deleteRequest(
242
          resolveEntityEndpoint(endpoint, id),
243
        );
244
        if (!response.ok) {
245
          throw new FetchError(response);
246
        }
247
      } catch (error) {
248
        if (isSubscribed.current) {
249
          dispatch({
250
            type: ActionTypes.DeleteReject,
251
            payload: error,
252
            meta: { id },
253
          });
254
          handleError(error);
255
        }
256
        throw error;
257
      }
258
      if (isSubscribed.current) {
259
        dispatch({
260
          type: ActionTypes.deleteFulfill,
261
          meta: { id },
262
        });
263
      }
264
    },
265
    [endpoint, resolveEntityEndpoint, handleError],
266
  );
267
268
  // Despite the usual guidlines, this should only be reconsidered if endpoint changes.
269
  // Changing doInitialRefresh after the first run (or refresh) should not cause this to rerun.
270
  useEffect(() => {
271
    if (doInitialRefresh) {
272
      refresh().catch(doNothing);
273
    }
274
    // eslint-disable-next-line react-hooks/exhaustive-deps
275
  }, [endpoint]);
276
277
  // Unsubscribe from promises when this hook is unmounted.
278
  useEffect(() => {
279
    return (): void => {
280
      isSubscribed.current = false;
281
    };
282
  }, []);
283
284
  return {
285
    values,
286
    indexStatus,
287
    entityStatus,
288
    create,
289
    refresh,
290
    update,
291
    deleteResource,
292
  };
293
}
294
295
export default useResourceIndex;
296