@financial-times/qanda-ui 0.0.1-beta.1

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.
Files changed (57) hide show
  1. package/.prettierrc +3 -0
  2. package/.toolkitrc.yml +34 -0
  3. package/.vscode/settings.json +4 -0
  4. package/CODEOWNERS +3 -0
  5. package/README.md +161 -0
  6. package/babel.config.json +10 -0
  7. package/dist/index.html +4 -0
  8. package/dist/index.js +2 -0
  9. package/dist/index.js.LICENSE.txt +15 -0
  10. package/dist/mockServiceWorker.js +307 -0
  11. package/jest.setup.ts +15 -0
  12. package/package.json +115 -0
  13. package/server.js +17 -0
  14. package/src/client/styles.scss +61 -0
  15. package/src/components/Author.tsx +45 -0
  16. package/src/components/CountdownTimer.tsx +73 -0
  17. package/src/components/Network.tsx +19 -0
  18. package/src/components/Qanda.tsx +51 -0
  19. package/src/components/QandaBlock.tsx +63 -0
  20. package/src/components/QandaContainer.tsx +60 -0
  21. package/src/components/QandaOComments.tsx +60 -0
  22. package/src/components/QandaProvider.tsx +13 -0
  23. package/src/components/QuestionForm.tsx +122 -0
  24. package/src/components/Status.tsx +20 -0
  25. package/src/components/Stream.tsx +49 -0
  26. package/src/components/UITestBar.tsx +76 -0
  27. package/src/components/UpdatesButton.tsx +45 -0
  28. package/src/components/UserInfo.tsx +57 -0
  29. package/src/index.tsx +58 -0
  30. package/src/services/comments-api.ts +217 -0
  31. package/src/services/custom-sse.ts +101 -0
  32. package/src/services/mocks/api-endpoints.ts +36 -0
  33. package/src/services/mocks/mock-comments.json +307 -0
  34. package/src/services/mocks/msw-browser.ts +4 -0
  35. package/src/services/mocks/msw-server.ts +4 -0
  36. package/src/store/index.ts +41 -0
  37. package/src/store/network.ts +23 -0
  38. package/src/store/qa.ts +22 -0
  39. package/src/store/questionSlice.ts +37 -0
  40. package/src/store/stream.ts +21 -0
  41. package/src/store/updateComments.ts +60 -0
  42. package/src/store/user.ts +55 -0
  43. package/src/types/async-operation.d.ts +4 -0
  44. package/src/types/comment.d.ts +19 -0
  45. package/src/types/globals.d.ts +1 -0
  46. package/src/types/modules.d.ts +1 -0
  47. package/src/types/o-types.d.ts +2 -0
  48. package/src/types/qanda.d.ts +7 -0
  49. package/src/types/sse-events.d.ts +6 -0
  50. package/src/types/x-dash.d.ts +6 -0
  51. package/src/utils/auth.ts +9 -0
  52. package/src/utils/mocks/update.ts +168 -0
  53. package/src/utils/qandas.ts +21 -0
  54. package/tsconfig.json +26 -0
  55. package/webpack.common.js +60 -0
  56. package/webpack.dev.js +26 -0
  57. package/webpack.prod.js +6 -0
@@ -0,0 +1,217 @@
1
+ import {
2
+ createApi,
3
+ fetchBaseQuery,
4
+ FetchBaseQueryError,
5
+ } from '@reduxjs/toolkit/query/react';
6
+
7
+ import customSSE from './custom-sse';
8
+ import type { Comment } from '../types/comment';
9
+ import { NewAnswerEvent } from '../types/sse-events';
10
+
11
+ // TODO: ensure that users don't miss out on updates in this window
12
+ // https://financialtimes.atlassian.net/browse/CI-2715
13
+ const retryDelay = 1000;
14
+
15
+ const updateTypes = {
16
+ QA_NEW_REPLY: 'updatedComments',
17
+ };
18
+
19
+ export let eventSource: EventSource | null = null;
20
+
21
+ type CommentsStreamResponse = {
22
+ type: string;
23
+ children?: Comment[];
24
+ startingDateTime?: string;
25
+ };
26
+
27
+ type UpdatesResponse = {
28
+ type: string;
29
+ payload: any; // maybe unknown?
30
+ };
31
+
32
+ type CachedData = {
33
+ updatedComments?: Comment[];
34
+ [key: string]: any; //fallback
35
+ };
36
+
37
+ export const nextCommentsApi = createApi({
38
+ reducerPath: 'nextCommentsApi',
39
+ refetchOnReconnect: true, // refetches all subscribed queries on regaining a network connection
40
+ baseQuery: fetchBaseQuery({
41
+ timeout: 10000,
42
+ }),
43
+ endpoints: (builder) => ({
44
+ // QA STREAM
45
+ getQandAStream: builder.query({
46
+ query: ({
47
+ storyId,
48
+ useStaging,
49
+ commentsAPIUrl,
50
+ }: {
51
+ storyId: string;
52
+ useStaging: boolean;
53
+ commentsAPIUrl: string;
54
+ }) =>
55
+ `${commentsAPIUrl}/story/${storyId}/stream${useStaging ? '?staging=1' : ''}`,
56
+ transformResponse: (response: CommentsStreamResponse) => {
57
+ return {
58
+ qandas: response?.children || [],
59
+ type: response.type,
60
+ startingDateTime: response?.startingDateTime || '',
61
+ };
62
+ },
63
+ }),
64
+ // UPDATES LISTENER
65
+ getQandAUpdates: builder.query({
66
+ queryFn: async ({
67
+ storyId,
68
+ useStaging,
69
+ commentsAPIUrl,
70
+ }: {
71
+ storyId: string;
72
+ useStaging: boolean;
73
+ commentsAPIUrl: string;
74
+ }) => {
75
+ return { data: {} as CachedData };
76
+ },
77
+ async onCacheEntryAdded(
78
+ arg,
79
+ { updateCachedData, cacheDataLoaded, cacheEntryRemoved },
80
+ ) {
81
+ await cacheDataLoaded;
82
+ let connectToSSE: () => void;
83
+ try {
84
+ connectToSSE = () => {
85
+ const sseEndpoint = `${arg.commentsAPIUrl}/story/${arg.storyId}/updates${arg.useStaging ? '?staging=1' : ''}`;
86
+ eventSource = new customSSE(sseEndpoint);
87
+
88
+ eventSource.onopen = () => {
89
+ console.log(`Connected to SSE: ${sseEndpoint}`);
90
+ };
91
+ eventSource.onmessage = (event) => {
92
+ try {
93
+ const data: NewAnswerEvent = JSON.parse(event.data);
94
+ console.log('Received SSE update:', data);
95
+ if (data && data.type) {
96
+ updateCachedData((draft: CachedData) => {
97
+ const updatedData = updateTypes[data.type];
98
+ draft[updatedData] = draft[updatedData] || [];
99
+ draft[updatedData].push(data.payload);
100
+ });
101
+ }
102
+ } catch (error) {
103
+ console.warn('Failed to parse SSE message:', event.data);
104
+ }
105
+ };
106
+ eventSource.onerror = (error) => {
107
+ console.error(
108
+ `SSE error occurred. Reconnecting in ${retryDelay / 1000} seconds...`,
109
+ );
110
+ eventSource?.close();
111
+ eventSource = null;
112
+
113
+ setTimeout(() => connectToSSE(), retryDelay);
114
+ };
115
+ };
116
+ connectToSSE();
117
+ } catch (error) {
118
+ console.error('Failed to subscribe to SSE:', error);
119
+ console.error(`Reconnecting in ${retryDelay / 1000} seconds...`);
120
+ eventSource = null;
121
+ setTimeout(() => connectToSSE(), retryDelay);
122
+ }
123
+ // cleanup when there are no subscribers to the cache anymore
124
+ await cacheEntryRemoved;
125
+ if (eventSource !== null) {
126
+ eventSource.close();
127
+ eventSource = null;
128
+ }
129
+ },
130
+ }),
131
+ // USER ROLES
132
+ getUserRoles: builder.query({
133
+ query: ({
134
+ storyId,
135
+ useStaging,
136
+ commentsAPIUrl,
137
+ token,
138
+ }: {
139
+ storyId: string;
140
+ useStaging: boolean;
141
+ commentsAPIUrl: string;
142
+ token: string;
143
+ }) => ({
144
+ url: `${commentsAPIUrl}/user/user-details/story/${storyId}${useStaging ? '?staging=1' : ''}`,
145
+ method: 'POST',
146
+ headers: {
147
+ Authorization: `Bearer ${token}`,
148
+ },
149
+ }),
150
+ }),
151
+ // POST A QUESTION
152
+ postQuestion: builder.mutation({
153
+ query: ({
154
+ storyId,
155
+ useStaging,
156
+ commentsAPIUrl,
157
+ token,
158
+ question,
159
+ }: {
160
+ storyId: string;
161
+ useStaging: boolean;
162
+ commentsAPIUrl: string;
163
+ token: string;
164
+ question: string;
165
+ }) => ({
166
+ url: `${commentsAPIUrl}/story/${storyId}/question${useStaging ? '?staging=1' : ''}`,
167
+ method: 'POST',
168
+ headers: {
169
+ 'Content-Type': 'application/json',
170
+ Authorization: `Bearer ${token}`,
171
+ },
172
+ body: JSON.stringify({ question, token }),
173
+ }),
174
+ transformResponse: (response: { data: any }, meta, arg) => {
175
+ return response.data;
176
+ },
177
+ transformErrorResponse: (response: FetchBaseQueryError, meta, arg) => {
178
+ if (response.data && typeof response.data === 'object') {
179
+ const errorResponse = response.data as { error?: string };
180
+ return errorResponse.error
181
+ ? errorResponse.error
182
+ : 'An unknown error occurred';
183
+ }
184
+ return 'An unknown error occurred';
185
+ },
186
+ }),
187
+ }),
188
+ });
189
+
190
+ export const {
191
+ useGetQandAStreamQuery,
192
+ useGetQandAUpdatesQuery,
193
+ useGetUserRolesQuery,
194
+ usePostQuestionMutation,
195
+ } = nextCommentsApi;
196
+
197
+ // UPDATES QUERY CUSTOM HOOKS
198
+
199
+ // custom hook for getting just the updated comments from the updates, so we are more efficient
200
+ export const useUpdatedComments = ({
201
+ storyId,
202
+ useStaging,
203
+ commentsAPIUrl,
204
+ }: {
205
+ storyId: string;
206
+ useStaging: boolean;
207
+ commentsAPIUrl: string;
208
+ }) => {
209
+ return nextCommentsApi.useGetQandAUpdatesQuery(
210
+ { storyId, useStaging, commentsAPIUrl },
211
+ {
212
+ selectFromResult: ({ data }) => ({
213
+ updatedComments: data?.updatedComments || [],
214
+ }),
215
+ },
216
+ ).updatedComments;
217
+ };
@@ -0,0 +1,101 @@
1
+ const MOCK_SSE =
2
+ process.env.NODE_ENV === 'development'
3
+ ? require('../../demo/config').MOCK_SSE
4
+ : false;
5
+
6
+ interface EventSourceInit {
7
+ withCredentials?: boolean;
8
+ }
9
+
10
+ interface EventSourceEventMap {
11
+ message: MessageEvent;
12
+ open: Event;
13
+ error: Event;
14
+ }
15
+
16
+ class MockEventSource {
17
+ static readonly CONNECTING = 0;
18
+ static readonly OPEN = 1;
19
+ static readonly CLOSED = 2;
20
+
21
+ readonly CONNECTING = MockEventSource.CONNECTING;
22
+ readonly OPEN = MockEventSource.OPEN;
23
+ readonly CLOSED = MockEventSource.CLOSED;
24
+
25
+ readyState: number = MockEventSource.CLOSED;
26
+ withCredentials: boolean = false;
27
+ url: string;
28
+ onopen: ((this: EventSource, ev: Event) => any) | null = null;
29
+ onmessage: ((this: EventSource, ev: MessageEvent) => any) | null = null;
30
+ onerror: ((this: EventSource, ev: Event) => any) | null = null;
31
+ isClosed = false;
32
+
33
+ constructor(url: string | URL, eventSourceInitDict?: EventSourceInit) {
34
+ this.url = url.toString();
35
+ if (eventSourceInitDict?.withCredentials) {
36
+ this.withCredentials = eventSourceInitDict.withCredentials;
37
+ }
38
+
39
+ // Simulate an async connection opening
40
+ setTimeout(() => {
41
+ if (this.onopen && !this.isClosed) {
42
+ this.onopen(new Event('open'));
43
+ }
44
+ }, 500);
45
+ }
46
+
47
+ addEventListener<K extends keyof EventSourceEventMap>(
48
+ type: K,
49
+ listener: (this: EventSource, ev: EventSourceEventMap[K]) => any,
50
+ options?: boolean | AddEventListenerOptions,
51
+ ): void {
52
+ // Mock implementation when needed
53
+ }
54
+
55
+ removeEventListener<K extends keyof EventSourceEventMap>(
56
+ type: K,
57
+ listener: (this: EventSource, ev: EventSourceEventMap[K]) => any,
58
+ options?: boolean | EventListenerOptions,
59
+ ): void {
60
+ // Mock implementation when needed
61
+ }
62
+
63
+ /**
64
+ * Simulate receiving a message from the SSE server.
65
+ */
66
+ sendMessage(data: any) {
67
+ if (this.onmessage && !this.isClosed) {
68
+ this.onmessage(
69
+ new MessageEvent('message', { data: JSON.stringify(data) }),
70
+ );
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Simulate an SSE error.
76
+ */
77
+ simulateError() {
78
+ if (this.onerror && !this.isClosed) {
79
+ this.onerror(new Event('error'));
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Close the event source.
85
+ */
86
+ close() {
87
+ this.isClosed = true;
88
+ this.readyState = MockEventSource.CLOSED;
89
+ }
90
+
91
+ dispatchEvent(event: Event): boolean {
92
+ // Mock implementation
93
+ return true;
94
+ }
95
+ }
96
+
97
+ const customSSE: typeof MockEventSource | typeof EventSource = MOCK_SSE
98
+ ? MockEventSource
99
+ : EventSource;
100
+
101
+ export default customSSE;
@@ -0,0 +1,36 @@
1
+ import { http, HttpResponse } from 'msw';
2
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
3
+ const mockComments = require('./mock-comments.json');
4
+
5
+ export const handlers = [
6
+ http.get('*/story/:storyId/stream', (req) => {
7
+ return HttpResponse.json(mockComments);
8
+ }),
9
+ http.get('*/story/:storyId/updates', (req) => {
10
+ // NB: this is probably not the correct response, but it's just a placeholder for now
11
+ return HttpResponse.json({
12
+ updatedComments: [
13
+ {
14
+ id: 'mocked-update-id',
15
+ body: { structured: { tree: { type: 'body', children: [] } } },
16
+ publishedDate: '2024-01-24T02:51:49.440Z',
17
+ byline: { structured: { tree: { type: 'byline', children: [] } } },
18
+ author: { role: 'Mocked Author' },
19
+ children: [],
20
+ },
21
+ ],
22
+ });
23
+ }),
24
+ http.post('*/user/user-details/story/:storyId', (req) => {
25
+ return HttpResponse.json({
26
+ isExpert: true,
27
+ isStaffExpert: true,
28
+ role: 'ADMIN',
29
+ });
30
+ }),
31
+ http.post('*/story/:storyId/question', (req) => {
32
+ return HttpResponse.json({
33
+ data: 'Question submitted successfully!',
34
+ });
35
+ }),
36
+ ];
@@ -0,0 +1,307 @@
1
+ {
2
+ "type": "qa",
3
+ "startingDateTime": "2025-02-28T21:19:07.877Z",
4
+ "children": [
5
+ {
6
+ "type": "comment",
7
+ "id": "37c43f17-cf7a-485c-9269-e705da9ad784",
8
+ "title": "",
9
+ "body": {
10
+ "structured": {
11
+ "tree": {
12
+ "type": "body",
13
+ "version": 1,
14
+ "children": [
15
+ {
16
+ "type": "paragraph",
17
+ "children": [
18
+ {
19
+ "type": "text",
20
+ "value": "Can the creator of Bitcoin / other cryptocurrencies change the maximum number of tokens that will be produced as to what is stated initially in its white paper?"
21
+ }
22
+ ]
23
+ }
24
+ ]
25
+ },
26
+ "references": []
27
+ }
28
+ },
29
+ "publishedDate": "2024-01-24T02:51:49.440Z",
30
+ "isQuestion": true,
31
+ "isAnswer": false,
32
+ "byline": "Robin C, London",
33
+ "children": [
34
+ {
35
+ "type": "comment",
36
+ "id": "aac43f17-cf5a-485c-9269-e705da9ad784",
37
+ "title": "",
38
+ "body": {
39
+ "structured": {
40
+ "tree": {
41
+ "type": "comment",
42
+ "version": 1,
43
+ "children": [
44
+ {
45
+ "type": "paragraph",
46
+ "children": [
47
+ {
48
+ "type": "text",
49
+ "value": "It's true that only a handful of people can approve whatever pull requests you or I might care to submit to a GitHub repository. However this element of centralization is offset by three things:—"
50
+ },
51
+ {
52
+ "type": "list",
53
+ "children": [
54
+ {
55
+ "type": "list-item",
56
+ "children": [
57
+ {
58
+ "type": "text",
59
+ "value": "The miners are free to accept "
60
+ },
61
+ {
62
+ "type": "emphasis",
63
+ "children": [
64
+ {
65
+ "type": "text",
66
+ "value": "or ignore"
67
+ }
68
+ ]
69
+ },
70
+ {
71
+ "type": "text",
72
+ "value": " the actions of the maintainers. The miners can download old versions if they wish (since git records all changes); or they can download their software from a rival repository (which a dissenting group of "
73
+ },
74
+ {
75
+ "type": "emphasis",
76
+ "children": [
77
+ {
78
+ "type": "text",
79
+ "value": "other"
80
+ }
81
+ ]
82
+ },
83
+ {
84
+ "type": "text",
85
+ "value": " maintainers would set up by cloning the original)"
86
+ }
87
+ ]
88
+ },
89
+ {
90
+ "type": "list-item",
91
+ "children": [
92
+ {
93
+ "type": "text",
94
+ "value": "Every active Bitcoin developer (including dissenters) has a personal copy of the whole repository (including its history of changes) on his/her own computer — that's how git works. GitHub is just a centralised point (owned by Microsoft) that individual developers can use (or not use) to synchronise their own personal git repositories against other individual developers’."
95
+ }
96
+ ]
97
+ }
98
+ ]
99
+ }
100
+ ]
101
+ }
102
+ ]
103
+ },
104
+ "references": []
105
+ }
106
+ },
107
+ "isQuestion": false,
108
+ "isAnswer": true,
109
+ "publishedDate": "2024-01-24T02:53:19.440Z",
110
+ "children": [],
111
+ "byline": {
112
+ "tree": {
113
+ "type": "byline",
114
+ "children": [
115
+ {
116
+ "type": "author",
117
+ "id": "d24990d1-2aec-4d72-8330-cb1c73532f49",
118
+ "children": [
119
+ {
120
+ "type": "text",
121
+ "value": "Rana Foroohar"
122
+ }
123
+ ],
124
+ "data": {
125
+ "referenceIndex": 0
126
+ }
127
+ }
128
+ ]
129
+ },
130
+ "references": [
131
+ {
132
+ "concept": {
133
+ "url": "https://www.ft.com/stream/d24990d1-2aec-4d72-8330-cb1c73532f49",
134
+ "relativeUrl": "/stream/d24990d1-2aec-4d72-8330-cb1c73532f49"
135
+ }
136
+ }
137
+ ]
138
+ },
139
+ "author": {
140
+ "role": "FT Reporter",
141
+ "headshot": "https://www.ft.com/__origami/service/image/v2/images/raw/https%3A%2F%2Fd1e00ek4ebabms.cloudfront.net%2Fproduction%2Fuploaded-files%2FRanaFaroohar-byline_cutout-48f06865-5fb8-49e2-99f1-e2781d64d566.png?source=next-article&fit=scale-down&quality=highest&width=150&dpr=2",
142
+ "prefLabel": "Rana Foroohar",
143
+ "streamPage": "https://www.ft.com/stream/d24990d1-2aec-4d72-8330-cb1c73532f49"
144
+ }
145
+ },
146
+ {
147
+ "type": "comment",
148
+ "id": "bbc43f17-cf7a-485c-9269-e705da9ad684",
149
+ "title": "",
150
+ "body": {
151
+ "structured": {
152
+ "tree": {
153
+ "type": "comment",
154
+ "version": 1,
155
+ "children": [
156
+ {
157
+ "type": "paragraph",
158
+ "children": [
159
+ {
160
+ "type": "text",
161
+ "value": "The source code for Bitcoin is published on GitHub. If you want to increase the number of tokens, here are some instructions:—"
162
+ },
163
+ {
164
+ "type": "list",
165
+ "children": [
166
+ {
167
+ "type": "list-item",
168
+ "children": [
169
+ {
170
+ "type": "text",
171
+ "value": "Open a GitHub account (free)."
172
+ }
173
+ ]
174
+ },
175
+ {
176
+ "type": "list-item",
177
+ "children": [
178
+ {
179
+ "type": "text",
180
+ "value": "Click the “clone me” button on the Bitcoin repository page to create your own copy of the Bitcoin source code. We'll call your new repository “BitcoinDither”."
181
+ }
182
+ ]
183
+ },
184
+ {
185
+ "type": "list-item",
186
+ "children": [
187
+ {
188
+ "type": "text",
189
+ "value": "Change the line(s) of code in your new BitcoinDither repository that specify the maximum number of tokens."
190
+ }
191
+ ]
192
+ }
193
+ ]
194
+ }
195
+ ]
196
+ }
197
+ ]
198
+ },
199
+ "references": []
200
+ }
201
+ },
202
+ "isQuestion": false,
203
+ "isAnswer": true,
204
+ "publishedDate": "2024-01-24T02:54:59.440Z",
205
+ "children": [],
206
+ "byline": {
207
+ "tree": {
208
+ "type": "byline",
209
+ "children": [
210
+ {
211
+ "type": "author",
212
+ "id": "e191658e-c66a-45bc-9bad-343bdc4210b3",
213
+ "children": [
214
+ {
215
+ "type": "text",
216
+ "value": "John Burn-Murdoch"
217
+ }
218
+ ],
219
+ "data": {
220
+ "referenceIndex": 0
221
+ }
222
+ }
223
+ ]
224
+ },
225
+ "references": [
226
+ {
227
+ "concept": {
228
+ "url": "https://www.ft.com/stream/e191658e-c66a-45bc-9bad-343bdc4210b3",
229
+ "relativeUrl": "/stream/e191658e-c66a-45bc-9bad-343bdc4210b3"
230
+ }
231
+ }
232
+ ]
233
+ },
234
+ "author": {
235
+ "role": "FT Reporter",
236
+ "streamPage": "https://www.ft.com/stream/e191658e-c66a-45bc-9bad-343bdc4210b3"
237
+ }
238
+ },
239
+ {
240
+ "type": "comment",
241
+ "id": "bbc43f17-cf7a-485c-9269-e705da9ad111",
242
+ "title": "",
243
+ "body": {
244
+ "structured": {
245
+ "tree": {
246
+ "type": "comment",
247
+ "version": 1,
248
+ "children": [
249
+ {
250
+ "type": "paragraph",
251
+ "children": [
252
+ {
253
+ "type": "text",
254
+ "value": "The source code for Bitcoin is published on GitHub. If you want to increase the number of tokens, here are some instructions:—"
255
+ },
256
+ {
257
+ "type": "list",
258
+ "children": [
259
+ {
260
+ "type": "list-item",
261
+ "children": [
262
+ {
263
+ "type": "text",
264
+ "value": "Open a GitHub account (free)."
265
+ }
266
+ ]
267
+ },
268
+ {
269
+ "type": "list-item",
270
+ "children": [
271
+ {
272
+ "type": "text",
273
+ "value": "Click the “clone me” button on the Bitcoin repository page to create your own copy of the Bitcoin source code. We'll call your new repository “BitcoinDither”."
274
+ }
275
+ ]
276
+ },
277
+ {
278
+ "type": "list-item",
279
+ "children": [
280
+ {
281
+ "type": "text",
282
+ "value": "Change the line(s) of code in your new BitcoinDither repository that specify the maximum number of tokens."
283
+ }
284
+ ]
285
+ }
286
+ ]
287
+ }
288
+ ]
289
+ }
290
+ ]
291
+ },
292
+ "references": []
293
+ }
294
+ },
295
+ "isQuestion": false,
296
+ "isAnswer": true,
297
+ "publishedDate": "2024-01-24T02:58:59.440Z",
298
+ "children": [],
299
+ "byline": "Jaren Kerr",
300
+ "author": {
301
+ "role": "FT Expert"
302
+ }
303
+ }
304
+ ]
305
+ }
306
+ ]
307
+ }
@@ -0,0 +1,4 @@
1
+ import { setupWorker } from 'msw/browser';
2
+ import { handlers } from './api-endpoints';
3
+
4
+ export const worker = setupWorker(...handlers);
@@ -0,0 +1,4 @@
1
+ import { setupServer } from 'msw/node';
2
+ import { handlers } from './api-endpoints';
3
+
4
+ export const server = setupServer(...handlers);