Passed
Push — feature/data-request-hooks ( 3b2561...a97593 )
by Tristan
05:11
created

indexResourceHook.ts ➔ defaultEntityEndpoint   A

Complexity

Conditions 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 3
dl 0
loc 6
rs 10
c 0
b 0
f 0
cc 1
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 { 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
export function useResourceIndex<T extends { id: number }>(
71
  endpoint: string,
72
  overrides?: {
73
    initialValue?: T[]; // Defaults to an empty list.
74
    forceInitialRefresh: boolean; // If you set an initialValue but also want to refresh immediately, set this to true.
75
    parseIndexResponse?: (response: Json) => T[];
76
    parseEntityResponse?: (response: Json) => T;
77
    resolveEntityEndpoint?: (baseEndpoint: string, id: number) => string;
78
    resolveCreateEndpoint?: (baseEndpoint: string, newEntity: T) => string;
79
    handleError?: (error: Error | FetchError) => void;
80
  },
81
): {
82
  values: IndexedObject<T>;
83
  indexStatus: ResourceStatus;
84
  entityStatus: IndexedObject<ResourceStatus>;
85
  create: (newValue: T) => Promise<T>;
86
  refresh: () => Promise<T[]>; // Reloads the entire index.
87
  update: (newValue: T) => Promise<T>;
88
  deleteResource: (id: number) => Promise<void>;
89
} {
90
  const initialValue = overrides?.initialValue ?? [];
91
  const doInitialRefresh =
92
    overrides?.initialValue === undefined ||
93
    overrides?.forceInitialRefresh === true;
94
  const parseIndexResponse = overrides?.parseIndexResponse ?? identity;
95
  const parseEntityResponse = overrides?.parseEntityResponse ?? identity;
96
  const resolveEntityEndpoint =
97
    overrides?.resolveEntityEndpoint ?? defaultEntityEndpoint;
98
  const resolveCreateEndpoint =
99
    overrides?.resolveCreateEndpoint ?? defaultCreateEndpoint;
100
  const handleError = overrides?.handleError ?? doNothing;
101
102
  const isSubscribed = useRef(true);
103
104
  const [state, dispatch] = useReducer<
105
    Reducer<ResourceState<T>, AsyncAction<T>>,
106
    T[] // This represent type of initialValue, passed to initializeState to create initial state.
107
  >(indexCrudReducer, initialValue, initializeState);
108
109
  const values = useMemo(() => valuesSelector(state), [state]);
110
  const indexStatus = state.indexMeta.status;
111
  const entityStatus = useMemo(() => statusSelector(state), [state]);
112
113
  const create = useCallback(
114
    async (newValue: T): Promise<T> => {
115
      dispatch({
116
        type: ActionTypes.createStart,
117
        meta: { item: newValue },
118
      });
119
      let json: Json;
120
      try {
121
        json = await postRequest(
122
          resolveCreateEndpoint(endpoint, newValue),
123
          newValue,
124
        ).then(processJsonResponse);
125
      } catch (error) {
126
        if (isSubscribed.current) {
127
          dispatch({
128
            type: ActionTypes.createReject,
129
            payload: error,
130
            meta: { item: newValue },
131
          });
132
        }
133
        handleError(error);
134
        throw error;
135
      }
136
      const entity = parseEntityResponse(json) as T;
137
      if (isSubscribed.current) {
138
        dispatch({
139
          type: ActionTypes.createFulfill,
140
          payload: entity,
141
          meta: { item: newValue },
142
        });
143
      }
144
      return entity;
145
    },
146
    [endpoint, resolveCreateEndpoint, parseEntityResponse, handleError],
147
  );
148
149
  const refresh = useCallback(async (): Promise<T[]> => {
150
    dispatch({
151
      type: ActionTypes.indexStart,
152
    });
153
    let json: Json;
154
    try {
155
      json = await getRequest(endpoint).then(processJsonResponse);
156
    } catch (error) {
157
      if (isSubscribed.current) {
158
        dispatch({
159
          type: ActionTypes.indexReject,
160
          payload: error,
161
        });
162
      }
163
      handleError(error);
164
      throw error;
165
    }
166
    const index = parseIndexResponse(json) as T[];
167
    if (isSubscribed.current) {
168
      dispatch({
169
        type: ActionTypes.indexFulfill,
170
        payload: index,
171
      });
172
    }
173
    return index;
174
  }, [endpoint, parseIndexResponse, handleError]);
175
176
  const update = useCallback(
177
    async (newValue: T): Promise<T> => {
178
      const meta = { id: newValue.id, item: newValue };
179
      dispatch({
180
        type: ActionTypes.updateStart,
181
        meta,
182
      });
183
      let json: Json;
184
      try {
185
        json = await putRequest(
186
          resolveEntityEndpoint(endpoint, newValue.id),
187
          newValue,
188
        ).then(processJsonResponse);
189
      } catch (error) {
190
        if (isSubscribed.current) {
191
          dispatch({
192
            type: ActionTypes.updateReject,
193
            payload: error,
194
            meta,
195
          });
196
        }
197
        handleError(error);
198
        throw error;
199
      }
200
      const value = parseEntityResponse(json) as T;
201
      if (isSubscribed.current) {
202
        dispatch({
203
          type: ActionTypes.updateFulfill,
204
          payload: value,
205
          meta,
206
        });
207
      }
208
      return value;
209
    },
210
    [endpoint, resolveEntityEndpoint, parseEntityResponse, handleError],
211
  );
212
213
  const deleteResource = useCallback(
214
    async (id: number): Promise<void> => {
215
      dispatch({
216
        type: ActionTypes.deleteStart,
217
        meta: { id },
218
      });
219
      try {
220
        const response = await deleteRequest(
221
          resolveEntityEndpoint(endpoint, id),
222
        );
223
        if (!response.ok) {
224
          throw new FetchError(response);
225
        }
226
      } catch (error) {
227
        if (isSubscribed.current) {
228
          dispatch({
229
            type: ActionTypes.deleteReject,
230
            payload: error,
231
            meta: { id },
232
          });
233
        }
234
        handleError(error);
235
        throw error;
236
      }
237
      if (isSubscribed.current) {
238
        dispatch({
239
          type: ActionTypes.deleteFulfill,
240
          meta: { id },
241
        });
242
      }
243
    },
244
    [endpoint, resolveEntityEndpoint, handleError],
245
  );
246
247
  // Despite the usual guidlines, this should only be reconsidered if endpoint changes.
248
  // Changing doInitialRefresh after the first run (or refresh) should not cause this to rerun.
249
  useEffect(() => {
250
    if (doInitialRefresh) {
251
      refresh();
252
    }
253
    // eslint-disable-next-line react-hooks/exhaustive-deps
254
  }, [endpoint]);
255
256
  // Unsubscribe from promises when this hook is unmounted.
257
  useEffect(() => {
258
    return (): void => {
259
      isSubscribed.current = false;
260
    };
261
  }, []);
262
263
  return {
264
    values,
265
    indexStatus,
266
    entityStatus,
267
    create,
268
    refresh,
269
    update,
270
    deleteResource,
271
  };
272
}
273
274
export default useResourceIndex;
275