@khanacademy/wonder-blocks-data 3.0.0 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,66 @@
1
+ // @flow
2
+ import * as React from "react";
3
+
4
+ import {GqlRouterContext} from "../util/gql-router-context.js";
5
+
6
+ import type {
7
+ GqlContext,
8
+ GqlFetchFn,
9
+ GqlRouterConfiguration,
10
+ } from "../util/gql-types.js";
11
+
12
+ type Props<TContext: GqlContext> = {|
13
+ /**
14
+ * The default context to be used by operations when no context is provided.
15
+ */
16
+ defaultContext: TContext,
17
+
18
+ /**
19
+ * The function to use when fetching requests.
20
+ */
21
+ fetch: GqlFetchFn<any, any, any, TContext>,
22
+
23
+ /**
24
+ * The children to be rendered inside the router.
25
+ */
26
+ children: React.Node,
27
+ |};
28
+
29
+ /**
30
+ * Configure GraphQL routing for GraphQL hooks and components.
31
+ *
32
+ * These can be nested. Components and hooks relying on the GraphQL routing
33
+ * will use the configuration from their closest ancestral GqlRouter.
34
+ */
35
+ export const GqlRouter = <TContext: GqlContext>({
36
+ defaultContext: thisDefaultContext,
37
+ fetch: thisFetch,
38
+ children,
39
+ }: Props<TContext>): React.Node => {
40
+ // We don't care if we're nested. We always force our callers to define
41
+ // everything. It makes for a clearer API and requires less error checking
42
+ // code (assuming our flow types are correct). We also don't default fetch
43
+ // to anything - our callers can tell us what function to use quite easily.
44
+ // If code that consumes this wants more nuanced nesting, it can implement
45
+ // it within its own GqlRouter than then defers to this one.
46
+
47
+ // We want to always use the same object if things haven't changed to avoid
48
+ // over-rendering consumers of our context, let's memoize the configuration.
49
+ // By doing this, if a component under children that uses this context
50
+ // uses React.memo, we won't force it to re-render every time we render
51
+ // because we'll only change the context value if something has actually
52
+ // changed.
53
+ const configuration: GqlRouterConfiguration<TContext> = React.useMemo(
54
+ () => ({
55
+ fetch: thisFetch,
56
+ defaultContext: thisDefaultContext,
57
+ }),
58
+ [thisDefaultContext, thisFetch],
59
+ );
60
+
61
+ return (
62
+ <GqlRouterContext.Provider value={configuration}>
63
+ {children}
64
+ </GqlRouterContext.Provider>
65
+ );
66
+ };
@@ -155,6 +155,42 @@ describe("#useData", () => {
155
155
  error: "ERROR",
156
156
  });
157
157
  });
158
+
159
+ it("should track the intercepted request", async () => {
160
+ // Arrange
161
+ const intercepted = Promise.resolve("INTERCEPTED");
162
+ const notIntercepted = Promise.resolve("NOT INTERCEPTED");
163
+ const fakeHandler: IRequestHandler<string, string> = {
164
+ fulfillRequest: jest.fn().mockReturnValue(notIntercepted),
165
+ getKey: (o) => o,
166
+ type: "MY_HANDLER",
167
+ hydrate: true,
168
+ };
169
+ const trackDataRequestSpy = jest.spyOn(
170
+ RequestTracker.Default,
171
+ "trackDataRequest",
172
+ );
173
+ const wrapper = ({children}) => (
174
+ <TrackData>
175
+ <InterceptData
176
+ fulfillRequest={() => intercepted}
177
+ handler={fakeHandler}
178
+ >
179
+ {children}
180
+ </InterceptData>
181
+ </TrackData>
182
+ );
183
+
184
+ // Act
185
+ serverRenderHook(() => useData(fakeHandler, "options"), {
186
+ wrapper,
187
+ });
188
+ const trackedHandler = trackDataRequestSpy.mock.calls[0][0];
189
+ const result = await trackedHandler.fulfillRequest();
190
+
191
+ // Assert
192
+ expect(result).toBe("INTERCEPTED");
193
+ });
158
194
  });
159
195
 
160
196
  describe("when client-side", () => {
@@ -668,122 +704,122 @@ describe("#useData", () => {
668
704
  data: "DATA",
669
705
  });
670
706
  });
671
- });
672
707
 
673
- describe("with interceptor", () => {
674
- it("should return the result of the interceptor request resolution", async () => {
675
- // Arrange
676
- const intercepted = Promise.resolve("INTERCEPTED");
677
- const notIntercepted = Promise.resolve("NOT INTERCEPTED");
678
- const fakeHandler: IRequestHandler<string, string> = {
679
- fulfillRequest: jest.fn().mockReturnValue(notIntercepted),
680
- getKey: (o) => o,
681
- type: "MY_HANDLER",
682
- hydrate: true,
683
- };
684
- const wrapper = ({children}) => (
685
- <InterceptData
686
- fulfillRequest={() => intercepted}
687
- handler={fakeHandler}
688
- >
689
- {children}
690
- </InterceptData>
691
- );
708
+ describe("with interceptor", () => {
709
+ it("should return the result of the interceptor request resolution", async () => {
710
+ // Arrange
711
+ const intercepted = Promise.resolve("INTERCEPTED");
712
+ const notIntercepted = Promise.resolve("NOT INTERCEPTED");
713
+ const fakeHandler: IRequestHandler<string, string> = {
714
+ fulfillRequest: jest.fn().mockReturnValue(notIntercepted),
715
+ getKey: (o) => o,
716
+ type: "MY_HANDLER",
717
+ hydrate: true,
718
+ };
719
+ const wrapper = ({children}) => (
720
+ <InterceptData
721
+ fulfillRequest={() => intercepted}
722
+ handler={fakeHandler}
723
+ >
724
+ {children}
725
+ </InterceptData>
726
+ );
692
727
 
693
- // Act
694
- const render = clientRenderHook(
695
- () => useData(fakeHandler, "options"),
696
- {
697
- wrapper,
698
- },
699
- );
700
- await act((): Promise<mixed> =>
701
- Promise.all([notIntercepted, intercepted]),
702
- );
703
- const result = render.result.current;
728
+ // Act
729
+ const render = clientRenderHook(
730
+ () => useData(fakeHandler, "options"),
731
+ {
732
+ wrapper,
733
+ },
734
+ );
735
+ await act((): Promise<mixed> =>
736
+ Promise.all([notIntercepted, intercepted]),
737
+ );
738
+ const result = render.result.current;
704
739
 
705
- // Assert
706
- expect(result).toEqual({
707
- status: "success",
708
- data: "INTERCEPTED",
740
+ // Assert
741
+ expect(result).toEqual({
742
+ status: "success",
743
+ data: "INTERCEPTED",
744
+ });
709
745
  });
710
- });
711
-
712
- it("should return the result of the interceptor request rejection", async () => {
713
- // Arrange
714
- const intercepted = Promise.reject("INTERCEPTED");
715
- const notIntercepted = Promise.resolve("NOT INTERCEPTED");
716
- const fakeHandler: IRequestHandler<string, string> = {
717
- fulfillRequest: jest.fn().mockReturnValue(notIntercepted),
718
- getKey: (o) => o,
719
- type: "MY_HANDLER",
720
- hydrate: true,
721
- };
722
- const wrapper = ({children}) => (
723
- <InterceptData
724
- fulfillRequest={() => intercepted}
725
- handler={fakeHandler}
726
- >
727
- {children}
728
- </InterceptData>
729
- );
730
746
 
731
- // Act
732
- const render = clientRenderHook(
733
- () => useData(fakeHandler, "options"),
734
- {
735
- wrapper,
736
- },
737
- );
738
- await notIntercepted;
739
- await act(async (): Promise<mixed> => {
740
- try {
741
- await intercepted;
742
- } catch (e) {
743
- /* ignore, it's ok */
744
- }
745
- });
746
- const result = render.result.current;
747
+ it("should return the result of the interceptor request rejection", async () => {
748
+ // Arrange
749
+ const intercepted = Promise.reject("INTERCEPTED");
750
+ const notIntercepted = Promise.resolve("NOT INTERCEPTED");
751
+ const fakeHandler: IRequestHandler<string, string> = {
752
+ fulfillRequest: jest.fn().mockReturnValue(notIntercepted),
753
+ getKey: (o) => o,
754
+ type: "MY_HANDLER",
755
+ hydrate: true,
756
+ };
757
+ const wrapper = ({children}) => (
758
+ <InterceptData
759
+ fulfillRequest={() => intercepted}
760
+ handler={fakeHandler}
761
+ >
762
+ {children}
763
+ </InterceptData>
764
+ );
747
765
 
748
- // Assert
749
- expect(result).toEqual({
750
- status: "error",
751
- error: "INTERCEPTED",
766
+ // Act
767
+ const render = clientRenderHook(
768
+ () => useData(fakeHandler, "options"),
769
+ {
770
+ wrapper,
771
+ },
772
+ );
773
+ await notIntercepted;
774
+ await act(async (): Promise<mixed> => {
775
+ try {
776
+ await intercepted;
777
+ } catch (e) {
778
+ /* ignore, it's ok */
779
+ }
780
+ });
781
+ const result = render.result.current;
782
+
783
+ // Assert
784
+ expect(result).toEqual({
785
+ status: "error",
786
+ error: "INTERCEPTED",
787
+ });
752
788
  });
753
- });
754
789
 
755
- it("should return the result of the handler if the interceptor returns null", async () => {
756
- // Arrange
757
- const notIntercepted = Promise.resolve("NOT INTERCEPTED");
758
- const fakeHandler: IRequestHandler<string, string> = {
759
- fulfillRequest: jest.fn().mockReturnValue(notIntercepted),
760
- getKey: (o) => o,
761
- type: "MY_HANDLER",
762
- hydrate: true,
763
- };
764
- const wrapper = ({children}) => (
765
- <InterceptData
766
- fulfillRequest={() => null}
767
- handler={fakeHandler}
768
- >
769
- {children}
770
- </InterceptData>
771
- );
790
+ it("should return the result of the handler if the interceptor returns null", async () => {
791
+ // Arrange
792
+ const notIntercepted = Promise.resolve("NOT INTERCEPTED");
793
+ const fakeHandler: IRequestHandler<string, string> = {
794
+ fulfillRequest: jest.fn().mockReturnValue(notIntercepted),
795
+ getKey: (o) => o,
796
+ type: "MY_HANDLER",
797
+ hydrate: true,
798
+ };
799
+ const wrapper = ({children}) => (
800
+ <InterceptData
801
+ fulfillRequest={() => null}
802
+ handler={fakeHandler}
803
+ >
804
+ {children}
805
+ </InterceptData>
806
+ );
772
807
 
773
- // Act
774
- const render = clientRenderHook(
775
- () => useData(fakeHandler, "options"),
776
- {
777
- wrapper,
778
- },
779
- );
780
- await act((): Promise<mixed> => notIntercepted);
781
- const result = render.result.current;
808
+ // Act
809
+ const render = clientRenderHook(
810
+ () => useData(fakeHandler, "options"),
811
+ {
812
+ wrapper,
813
+ },
814
+ );
815
+ await act((): Promise<mixed> => notIntercepted);
816
+ const result = render.result.current;
782
817
 
783
- // Assert
784
- expect(result).toEqual({
785
- status: "success",
786
- data: "NOT INTERCEPTED",
818
+ // Assert
819
+ expect(result).toEqual({
820
+ status: "success",
821
+ data: "NOT INTERCEPTED",
822
+ });
787
823
  });
788
824
  });
789
825
  });
@@ -0,0 +1,233 @@
1
+ // @flow
2
+ import * as React from "react";
3
+ import {renderHook} from "@testing-library/react-hooks";
4
+
5
+ import * as GetGqlDataFromResponse from "../../util/get-gql-data-from-response.js";
6
+ import {GqlRouterContext} from "../../util/gql-router-context.js";
7
+ import {useGql} from "../use-gql.js";
8
+
9
+ describe("#useGql", () => {
10
+ beforeEach(() => {
11
+ jest.resetAllMocks();
12
+ });
13
+
14
+ it("should throw if there is no GqlRouterContext available", () => {
15
+ // Arrange
16
+
17
+ // Act
18
+ const {
19
+ result: {error: result},
20
+ } = renderHook(() => useGql());
21
+
22
+ // Assert
23
+ expect(result).toMatchInlineSnapshot(
24
+ `[GqlInternalError: No GqlRouter]`,
25
+ );
26
+ });
27
+
28
+ it("should return a function", () => {
29
+ // Arrange
30
+ const gqlRouterContext = {
31
+ fetch: jest.fn(),
32
+ defaultContext: {},
33
+ };
34
+
35
+ // Act
36
+ const {
37
+ result: {current: result},
38
+ } = renderHook(() => useGql(), {
39
+ wrapper: ({children}) => (
40
+ <GqlRouterContext.Provider value={gqlRouterContext}>
41
+ {children}
42
+ </GqlRouterContext.Provider>
43
+ ),
44
+ });
45
+
46
+ // Assert
47
+ expect(result).toBeInstanceOf(Function);
48
+ });
49
+
50
+ describe("returned gqlFetch", () => {
51
+ it("should fetch the operation with combined context", async () => {
52
+ // Arrange
53
+ jest.spyOn(
54
+ GetGqlDataFromResponse,
55
+ "getGqlDataFromResponse",
56
+ ).mockResolvedValue({
57
+ some: "data",
58
+ });
59
+ const fetchFake = jest
60
+ .fn()
61
+ .mockResolvedValue(("FAKE_RESPONSE": any));
62
+ const gqlRouterContext = {
63
+ fetch: fetchFake,
64
+ defaultContext: {
65
+ a: "defaultA",
66
+ b: "defaultB",
67
+ },
68
+ };
69
+ const {
70
+ result: {current: gqlFetch},
71
+ } = renderHook(() => useGql(), {
72
+ wrapper: ({children}) => (
73
+ <GqlRouterContext.Provider value={gqlRouterContext}>
74
+ {children}
75
+ </GqlRouterContext.Provider>
76
+ ),
77
+ });
78
+ const gqlOp = {
79
+ type: "query",
80
+ id: "MyQuery",
81
+ };
82
+ const gqlOpContext = {
83
+ b: "overrideB",
84
+ };
85
+ const gqlOpVariables = {
86
+ var1: "val1",
87
+ };
88
+
89
+ // Act
90
+ await gqlFetch(gqlOp, {
91
+ context: gqlOpContext,
92
+ variables: gqlOpVariables,
93
+ });
94
+
95
+ // Assert
96
+ expect(fetchFake).toHaveBeenCalledWith(gqlOp, gqlOpVariables, {
97
+ a: "defaultA",
98
+ b: "overrideB",
99
+ });
100
+ });
101
+
102
+ it("should parse the response", async () => {
103
+ // Arrange
104
+ const getGqlDataFromResponseSpy = jest
105
+ .spyOn(GetGqlDataFromResponse, "getGqlDataFromResponse")
106
+ .mockResolvedValue({
107
+ some: "data",
108
+ });
109
+ const gqlRouterContext = {
110
+ fetch: jest.fn().mockResolvedValue(("FAKE_RESPONSE": any)),
111
+ defaultContext: {},
112
+ };
113
+ const {
114
+ result: {current: gqlFetch},
115
+ } = renderHook(() => useGql(), {
116
+ wrapper: ({children}) => (
117
+ <GqlRouterContext.Provider value={gqlRouterContext}>
118
+ {children}
119
+ </GqlRouterContext.Provider>
120
+ ),
121
+ });
122
+ const gqlOp = {
123
+ type: "query",
124
+ id: "MyQuery",
125
+ };
126
+
127
+ // Act
128
+ await gqlFetch(gqlOp);
129
+
130
+ // Assert
131
+ expect(getGqlDataFromResponseSpy).toHaveBeenCalledWith(
132
+ "FAKE_RESPONSE",
133
+ );
134
+ });
135
+
136
+ it("should reject if the response parse rejects", async () => {
137
+ // Arrange
138
+ jest.spyOn(
139
+ GetGqlDataFromResponse,
140
+ "getGqlDataFromResponse",
141
+ ).mockRejectedValue(new Error("FAKE_ERROR"));
142
+ const gqlRouterContext = {
143
+ fetch: jest.fn().mockResolvedValue(("FAKE_RESPONSE": any)),
144
+ defaultContext: {},
145
+ };
146
+ const {
147
+ result: {current: gqlFetch},
148
+ } = renderHook(() => useGql(), {
149
+ wrapper: ({children}) => (
150
+ <GqlRouterContext.Provider value={gqlRouterContext}>
151
+ {children}
152
+ </GqlRouterContext.Provider>
153
+ ),
154
+ });
155
+ const gqlOp = {
156
+ type: "query",
157
+ id: "MyQuery",
158
+ };
159
+
160
+ // Act
161
+ const act = gqlFetch(gqlOp);
162
+
163
+ // Assert
164
+ await expect(act).rejects.toThrowErrorMatchingInlineSnapshot(
165
+ `"FAKE_ERROR"`,
166
+ );
167
+ });
168
+
169
+ it("should resolve to null if the fetch was aborted", async () => {
170
+ // Arrange
171
+ const abortError = new Error("Aborted");
172
+ abortError.name = "AbortError";
173
+ const gqlRouterContext = {
174
+ fetch: jest.fn().mockRejectedValue(abortError),
175
+ defaultContext: {},
176
+ };
177
+ const {
178
+ result: {current: gqlFetch},
179
+ } = renderHook(() => useGql(), {
180
+ wrapper: ({children}) => (
181
+ <GqlRouterContext.Provider value={gqlRouterContext}>
182
+ {children}
183
+ </GqlRouterContext.Provider>
184
+ ),
185
+ });
186
+ const gqlOp = {
187
+ type: "query",
188
+ id: "MyQuery",
189
+ };
190
+
191
+ // Act
192
+ const result = await gqlFetch(gqlOp);
193
+
194
+ // Assert
195
+ expect(result).toBeNull();
196
+ });
197
+
198
+ it("should resolve to the response data", async () => {
199
+ // Arrange
200
+ jest.spyOn(
201
+ GetGqlDataFromResponse,
202
+ "getGqlDataFromResponse",
203
+ ).mockResolvedValue({
204
+ some: "data",
205
+ });
206
+ const gqlRouterContext = {
207
+ fetch: jest.fn().mockResolvedValue(("FAKE_RESPONSE": any)),
208
+ defaultContext: {},
209
+ };
210
+ const {
211
+ result: {current: gqlFetch},
212
+ } = renderHook(() => useGql(), {
213
+ wrapper: ({children}) => (
214
+ <GqlRouterContext.Provider value={gqlRouterContext}>
215
+ {children}
216
+ </GqlRouterContext.Provider>
217
+ ),
218
+ });
219
+ const gqlOp = {
220
+ type: "mutation",
221
+ id: "MyMutation",
222
+ };
223
+
224
+ // Act
225
+ const result = await gqlFetch(gqlOp);
226
+
227
+ // Assert
228
+ expect(result).toEqual({
229
+ some: "data",
230
+ });
231
+ });
232
+ });
233
+ });
@@ -29,20 +29,41 @@ export const useData = <TOptions, TData: ValidData>(
29
29
  );
30
30
  const [result, setResult] = useState<?CacheEntry<TData>>(cachedResult);
31
31
 
32
+ // Lookup to see if there's an interceptor for the handler.
33
+ // If we have one, we need to replace the handler with one that
34
+ // uses the interceptor.
35
+ const interceptorMap = useContext(InterceptContext);
36
+ const interceptor = interceptorMap[handler.type];
37
+
38
+ // If we have an interceptor, we need to replace the handler with one that
39
+ // uses the interceptor. This helper function generates a new handler.
40
+ // We need this before we track the request as we want the interceptor
41
+ // to also work for tracked requests to simplify testing the server-side
42
+ // request fulfillment.
43
+ const getMaybeInterceptedHandler = () => {
44
+ if (interceptor == null) {
45
+ return handler;
46
+ }
47
+
48
+ const fulfillRequestFn = (options) =>
49
+ interceptor.fulfillRequest(options) ??
50
+ handler.fulfillRequest(options);
51
+ return {
52
+ fulfillRequest: fulfillRequestFn,
53
+ getKey: (options) => handler.getKey(options),
54
+ type: handler.type,
55
+ hydrate: handler.hydrate,
56
+ };
57
+ };
58
+
32
59
  // We only track data requests when we are server-side and we don't
33
60
  // already have a result, as given by the cachedData (which is also the
34
61
  // initial value for the result state).
35
62
  const maybeTrack = useContext(TrackerContext);
36
63
  if (result == null && Server.isServerSide()) {
37
- maybeTrack?.(handler, options);
64
+ maybeTrack?.(getMaybeInterceptedHandler(), options);
38
65
  }
39
66
 
40
- // Lookup to see if there's an interceptor for the handler.
41
- // If we have one, we need to replace the handler with one that
42
- // uses the interceptor.
43
- const interceptorMap = useContext(InterceptContext);
44
- const interceptor = interceptorMap[handler.type];
45
-
46
67
  // We need to update our request when the handler changes or the key
47
68
  // to the options change, so we keep track of those.
48
69
  // However, even if we are hydrating from cache, we still need to make the
@@ -74,22 +95,6 @@ export const useData = <TOptions, TData: ValidData>(
74
95
  setResult(null);
75
96
  }
76
97
 
77
- const getMaybeInterceptedHandler = () => {
78
- if (interceptor == null) {
79
- return handler;
80
- }
81
-
82
- const fulfillRequestFn = (options) =>
83
- interceptor.fulfillRequest(options) ??
84
- handler.fulfillRequest(options);
85
- return {
86
- fulfillRequest: fulfillRequestFn,
87
- getKey: (options) => handler.getKey(options),
88
- type: handler.type,
89
- hydrate: handler.hydrate,
90
- };
91
- };
92
-
93
98
  // We aren't server-side, so let's make the request.
94
99
  // The request handler is in control of whether that request actually
95
100
  // happens or not.