@khanacademy/wonder-blocks-data 3.0.1 → 3.1.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanacademy/wonder-blocks-data",
3
- "version": "3.0.1",
3
+ "version": "3.1.0",
4
4
  "design": "v1",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -17,6 +17,7 @@
17
17
  "@khanacademy/wonder-blocks-core": "^4.0.0"
18
18
  },
19
19
  "peerDependencies": {
20
+ "@khanacademy/wonder-stuff-core": "^0.1.2",
20
21
  "react": "16.14.0"
21
22
  },
22
23
  "devDependencies": {
@@ -0,0 +1,64 @@
1
+ // @flow
2
+ import * as React from "react";
3
+ import {render} from "@testing-library/react";
4
+
5
+ import {GqlRouterContext} from "../../util/gql-router-context.js";
6
+ import {GqlRouter} from "../gql-router.js";
7
+
8
+ describe("GqlRouter", () => {
9
+ it("should provide the GqlRouterContext as configured", async () => {
10
+ // Arrange
11
+ const defaultContext = {
12
+ foo: "bar",
13
+ };
14
+ const fetch = jest.fn();
15
+ const CaptureContext = ({captureFn}) => {
16
+ captureFn(React.useContext(GqlRouterContext));
17
+ return null;
18
+ };
19
+
20
+ // Act
21
+ const result = await new Promise((resolve, reject) => {
22
+ render(
23
+ <GqlRouter defaultContext={defaultContext} fetch={fetch}>
24
+ <CaptureContext captureFn={resolve} />
25
+ </GqlRouter>,
26
+ );
27
+ });
28
+
29
+ // Assert
30
+ expect(result).toStrictEqual({
31
+ defaultContext,
32
+ fetch,
33
+ });
34
+ });
35
+
36
+ it("should not render React.memo-ized children if props remain the same", () => {
37
+ // Arrange
38
+ const defaultContext = {
39
+ foo: "bar",
40
+ };
41
+ const fetch = jest.fn();
42
+ let renderCount = 0;
43
+ const Child = React.memo(() => {
44
+ const context = React.useContext(GqlRouterContext);
45
+ renderCount++;
46
+ return <div>{JSON.stringify(context)}</div>;
47
+ });
48
+
49
+ // Act
50
+ const {rerender} = render(
51
+ <GqlRouter defaultContext={defaultContext} fetch={fetch}>
52
+ <Child />
53
+ </GqlRouter>,
54
+ );
55
+ rerender(
56
+ <GqlRouter defaultContext={defaultContext} fetch={fetch}>
57
+ <Child />
58
+ </GqlRouter>,
59
+ );
60
+
61
+ // Assert
62
+ expect(renderCount).toBe(1);
63
+ });
64
+ });
@@ -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
+ FetchFn,
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: FetchFn<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
+ };
@@ -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
+ });
@@ -0,0 +1,75 @@
1
+ // @flow
2
+ import {useContext, useMemo} from "react";
3
+
4
+ import {GqlRouterContext} from "../util/gql-router-context.js";
5
+ import {getGqlDataFromResponse} from "../util/get-gql-data-from-response.js";
6
+ import {GqlError, GqlErrors} from "../util/gql-error.js";
7
+
8
+ import type {
9
+ GqlContext,
10
+ GqlOperation,
11
+ GqlFetchOptions,
12
+ GqlOperationType,
13
+ } from "../util/gql-types.js";
14
+
15
+ /**
16
+ * Hook to obtain a gqlFetch function for performing GraphQL requests.
17
+ *
18
+ * The fetch function will resolve null if the request was aborted, otherwise
19
+ * it will resolve the data returned by the GraphQL server.
20
+ */
21
+ export const useGql = (): (<
22
+ TType: GqlOperationType,
23
+ TData,
24
+ TVariables: {...},
25
+ TContext: GqlContext,
26
+ >(
27
+ operation: GqlOperation<TType, TData, TVariables>,
28
+ options?: GqlFetchOptions<TVariables, TContext>,
29
+ ) => Promise<?TData>) => {
30
+ // This hook only works if the `GqlRouter` has been used to setup context.
31
+ const gqlRouterContext = useContext(GqlRouterContext);
32
+ if (gqlRouterContext == null) {
33
+ throw new GqlError("No GqlRouter", GqlErrors.Internal);
34
+ }
35
+ const {fetch, defaultContext} = gqlRouterContext;
36
+
37
+ // Let's memoize the gqlFetch function we create based off our context.
38
+ // That way, even if the context happens to change, if its values don't
39
+ // we give the same function instance back to our callers instead of
40
+ // making a new one. That then means they can safely use the return value
41
+ // in hooks deps without fear of it triggering extra renders.
42
+ const gqlFetch = useMemo(
43
+ () =>
44
+ <
45
+ TType: GqlOperationType,
46
+ TData,
47
+ TVariables: {...},
48
+ TContext: GqlContext,
49
+ >(
50
+ operation: GqlOperation<TType, TData, TVariables>,
51
+ options: GqlFetchOptions<TVariables, TContext> = Object.freeze(
52
+ {},
53
+ ),
54
+ ) => {
55
+ const {variables, context} = options;
56
+
57
+ // Invoke the fetch and extract the data.
58
+ return fetch(operation, variables, {
59
+ ...defaultContext,
60
+ ...context,
61
+ }).then(getGqlDataFromResponse, (error) => {
62
+ // Return null if the request was aborted.
63
+ // The only way to detect this reliably, it seems, is to
64
+ // check the error name and see if it's "AbortError" (this
65
+ // is also what Apollo does).
66
+ // Even then, it's reliant on the fetch supporting aborts.
67
+ if (error.name === "AbortError") {
68
+ return null;
69
+ }
70
+ });
71
+ },
72
+ [fetch, defaultContext],
73
+ );
74
+ return gqlFetch;
75
+ };
package/src/index.js CHANGED
@@ -50,14 +50,14 @@ export const removeAllFromCache = <TOptions, TData: ValidData>(
50
50
  ) => boolean,
51
51
  ): number => ResCache.Default.removeAll<TOptions, TData>(handler, predicate);
52
52
 
53
- /**
54
- * TODO(somewhatabstract): Export each cache type we implement.
55
- *
56
- * Is there a base type we export, like we do for RequestHandler?
57
- */
58
-
59
53
  export {default as RequestHandler} from "./util/request-handler.js";
60
54
  export {default as TrackData} from "./components/track-data.js";
61
55
  export {default as Data} from "./components/data.js";
62
56
  export {default as InterceptData} from "./components/intercept-data.js";
63
57
  export {useData} from "./hooks/use-data.js";
58
+
59
+ // GraphQL
60
+ export {GqlRouter} from "./components/gql-router.js";
61
+ export {useGql} from "./hooks/use-gql.js";
62
+ export * from "./util/gql-error.js";
63
+ export type {GqlContext, GqlOperation} from "./util/gql-types.js";
@@ -0,0 +1,187 @@
1
+ // @flow
2
+ import {getGqlDataFromResponse} from "../get-gql-data-from-response.js";
3
+
4
+ describe("#getGqlDataFromReponse", () => {
5
+ it("should throw if the response cannot be parsed", async () => {
6
+ // Arrange
7
+ const response: any = {
8
+ status: 200,
9
+ text: jest.fn(() => Promise.resolve("BAD JSON")),
10
+ };
11
+
12
+ // Act
13
+ const result = getGqlDataFromResponse(response);
14
+
15
+ // Assert
16
+ await expect(result).rejects.toThrowErrorMatchingInlineSnapshot(`
17
+ "Failed to parse response
18
+ caused by
19
+ SyntaxError: Unexpected token B in JSON at position 0"
20
+ `);
21
+ });
22
+
23
+ it("should include status code and body text in parse error metadata", async () => {
24
+ // Arrange
25
+ const response: any = {
26
+ status: 200,
27
+ text: jest.fn(() => Promise.resolve("BAD JSON")),
28
+ };
29
+
30
+ // Act
31
+ const result = getGqlDataFromResponse(response);
32
+
33
+ // Assert
34
+ await expect(result).rejects.toHaveProperty("metadata", {
35
+ statusCode: 200,
36
+ bodyText: "BAD JSON",
37
+ });
38
+ });
39
+
40
+ it("should throw if the status code is not <300", async () => {
41
+ // Arrange
42
+ const response: any = {
43
+ status: 400,
44
+ text: jest.fn(() => Promise.resolve("{}")),
45
+ };
46
+
47
+ // Act
48
+ const result = getGqlDataFromResponse(response);
49
+
50
+ // Assert
51
+ await expect(result).rejects.toThrowErrorMatchingInlineSnapshot(
52
+ `"Response unsuccessful"`,
53
+ );
54
+ });
55
+
56
+ it("should include status code and result in response error metadata", async () => {
57
+ // Arrange
58
+ const response: any = {
59
+ status: 400,
60
+ text: jest.fn(() =>
61
+ Promise.resolve(JSON.stringify({data: "DATA"})),
62
+ ),
63
+ };
64
+
65
+ // Act
66
+ const result = getGqlDataFromResponse(response);
67
+
68
+ // Assert
69
+ await expect(result).rejects.toHaveProperty("metadata", {
70
+ statusCode: 400,
71
+ result: {
72
+ data: "DATA",
73
+ },
74
+ });
75
+ });
76
+
77
+ it("should throw if the response is malformed", async () => {
78
+ // Arrange
79
+ const response: any = {
80
+ status: 200,
81
+ text: jest.fn(() => Promise.resolve("{}")),
82
+ };
83
+
84
+ // Act
85
+ const result = getGqlDataFromResponse(response);
86
+
87
+ // Assert
88
+ await expect(result).rejects.toThrowErrorMatchingInlineSnapshot(
89
+ `"Server response missing"`,
90
+ );
91
+ });
92
+
93
+ it("should include the status code and the result in the malformed response error", async () => {
94
+ // Arrange
95
+ const response: any = {
96
+ status: 200,
97
+ text: jest.fn(() =>
98
+ Promise.resolve(JSON.stringify({malformed: "response"})),
99
+ ),
100
+ };
101
+
102
+ // Act
103
+ const result = getGqlDataFromResponse(response);
104
+
105
+ // Assert
106
+ await expect(result).rejects.toHaveProperty("metadata", {
107
+ statusCode: 200,
108
+ result: {
109
+ malformed: "response",
110
+ },
111
+ });
112
+ });
113
+
114
+ it("should throw if the response has GraphQL errors", async () => {
115
+ // Arrange
116
+ const response: any = {
117
+ status: 200,
118
+ text: jest.fn(() =>
119
+ Promise.resolve(
120
+ JSON.stringify({
121
+ data: {},
122
+ errors: [{message: "GraphQL error"}],
123
+ }),
124
+ ),
125
+ ),
126
+ };
127
+
128
+ // Act
129
+ const result = getGqlDataFromResponse(response);
130
+
131
+ // Assert
132
+ await expect(result).rejects.toThrowErrorMatchingInlineSnapshot(
133
+ `"GraphQL errors"`,
134
+ );
135
+ });
136
+
137
+ it("should include the status code and result in the metadata", async () => {
138
+ // Arrange
139
+ const response: any = {
140
+ status: 200,
141
+ text: jest.fn(() =>
142
+ Promise.resolve(
143
+ JSON.stringify({
144
+ data: {},
145
+ errors: [{message: "GraphQL error"}],
146
+ }),
147
+ ),
148
+ ),
149
+ };
150
+
151
+ // Act
152
+ const result = getGqlDataFromResponse(response);
153
+
154
+ // Assert
155
+ await expect(result).rejects.toHaveProperty("metadata", {
156
+ statusCode: 200,
157
+ result: {
158
+ data: {},
159
+ errors: [{message: "GraphQL error"}],
160
+ },
161
+ });
162
+ });
163
+
164
+ it("should resolve to the response data", async () => {
165
+ // Arrange
166
+ const response: any = {
167
+ status: 200,
168
+ text: jest.fn(() =>
169
+ Promise.resolve(
170
+ JSON.stringify({
171
+ data: {
172
+ test: "test",
173
+ },
174
+ }),
175
+ ),
176
+ ),
177
+ };
178
+
179
+ // Act
180
+ const result = getGqlDataFromResponse(response);
181
+
182
+ // Assert
183
+ await expect(result).resolves.toEqual({
184
+ test: "test",
185
+ });
186
+ });
187
+ });