-
Notifications
You must be signed in to change notification settings - Fork 979
/
createCell.tsx
311 lines (291 loc) · 8.05 KB
/
createCell.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
import type { ComponentProps, JSXElementConstructor } from 'react'
import type { DocumentNode } from 'graphql'
import type { A } from 'ts-toolbelt'
/**
* This is part of how we let users swap out their GraphQL client while staying compatible with Cells.
*/
import { useQuery } from './GraphQLHooksProvider'
/**
* Cell component props which is the combination of query variables and Success props.
*/
export type CellProps<
CellSuccess extends keyof JSX.IntrinsicElements | JSXElementConstructor<any>,
GQLResult,
GQLVariables
> = A.Compute<
Omit<
ComponentProps<CellSuccess>,
keyof QueryOperationResult | keyof GQLResult | 'updating'
> &
(GQLVariables extends { [key: string]: never } ? unknown : GQLVariables)
>
export type CellLoadingProps<TVariables = any> = Partial<
Omit<QueryOperationResult<any, TVariables>, 'loading' | 'error' | 'data'>
>
export type CellFailureProps<TVariables = any> = Partial<
Omit<QueryOperationResult<any, TVariables>, 'loading' | 'error' | 'data'> & {
error: QueryOperationResult['error'] | Error // for tests and storybook
/**
* @see {@link https://www.apollographql.com/docs/apollo-server/data/errors/#error-codes}
*/
errorCode: string
updating: boolean
}
>
/**
* @MARK not sure about this partial, but we need to do this for tests and storybook.
*
* `updating` is just `loading` renamed; since Cells default to stale-while-refetch,
* this prop lets users render something like a spinner to show that a request is in-flight.
*/
export type CellSuccessProps<TData = any, TVariables = any> = Partial<
Omit<
QueryOperationResult<TData, TVariables>,
'loading' | 'error' | 'data'
> & {
updating: boolean
}
> &
A.Compute<TData> // pre-computing makes the types more readable on hover
/**
* A coarse type for the `data` prop returned by `useQuery`.
*
* ```js
* {
* data: {
* post: { ... }
* }
* }
* ```
*/
export type DataObject = { [key: string]: unknown }
/**
* The main interface.
*/
export interface CreateCellProps<CellProps> {
/**
* The GraphQL syntax tree to execute or function to call that returns it.
* If `QUERY` is a function, it's called with the result of `beforeQuery`.
*/
QUERY: DocumentNode | ((variables: Record<string, unknown>) => DocumentNode)
/**
* Parse `props` into query variables. Most of the time `props` are appropriate variables as is.
*/
beforeQuery?: <TProps>(props: TProps) => { variables: TProps }
/**
* Sanitize the data returned from the query.
*/
afterQuery?: (data: DataObject) => DataObject
/**
* How to decide if the result of a query should render the `Empty` component.
* The default implementation checks that the first field isn't `null` or an empty array.
*
* @example
*
* In the example below, only `users` is checked:
*
* ```js
* export const QUERY = gql`
* users {
* name
* }
* posts {
* title
* }
* `
* ```
*/
isEmpty?: (
response: DataObject,
options: {
isDataEmpty: (data: DataObject) => boolean
}
) => boolean
/**
* If the query's in flight and there's no stale data, render this.
*/
Loading?: React.FC<CellLoadingProps & Partial<CellProps>>
/**
* If something went wrong, render this.
*/
Failure?: React.FC<CellFailureProps & Partial<CellProps>>
/**
* If no data was returned, render this.
*/
Empty?: React.FC<CellSuccessProps & Partial<CellProps>>
/**
* If data was returned, render this.
*/
Success: React.FC<CellSuccessProps & Partial<CellProps>>
/**
* What to call the Cell. Defaults to the filename.
*/
displayName?: string
}
/**
* The default `isEmpty` implementation. Checks if the first field is `null` or an empty array.
*
* @remarks
*
* Consider the following queries. The former returns an object, the latter a list:
*
* ```js
* export const QUERY = gql`
* post {
* title
* }
* `
*
* export const QUERY = gql`
* posts {
* title
* }
* `
* ```
*
* If either are "empty", they return:
*
* ```js
* {
* data: {
* post: null
* }
* }
*
* {
* data: {
* posts: []
* }
* }
* ```
*
* Note that the latter can return `null` as well depending on the SDL (`posts: [Post!]`).
*
* @remarks
*
* We only check the first field (in the example below, `users`):
*
* ```js
* export const QUERY = gql`
* users {
* name
* }
* posts {
* title
* }
* `
* ```
*/
const dataField = (data: DataObject) => {
return data[Object.keys(data)[0]]
}
const isDataNull = (data: DataObject) => {
return dataField(data) === null
}
const isDataEmptyArray = (data: DataObject) => {
const field = dataField(data)
return Array.isArray(field) && field.length === 0
}
const isDataEmpty = (data: DataObject) => {
return isDataNull(data) || isDataEmptyArray(data)
}
/**
* Creates a Cell out of a GraphQL query and components that track to its lifecycle.
*/
export function createCell<CellProps = any>({
QUERY,
beforeQuery = (props) => ({
variables: props,
/**
* We're duplicating these props here due to a suspected bug in Apollo Client v3.5.4
* (it doesn't seem to be respecting `defaultOptions` in `RedwoodApolloProvider`.)
*
* @see {@link https://github.com/apollographql/apollo-client/issues/9105}
*/
fetchPolicy: 'cache-and-network',
notifyOnNetworkStatusChange: true,
}),
afterQuery = (data) => ({ ...data }),
isEmpty = isDataEmpty,
Loading = () => <>Loading...</>,
Failure,
Empty,
Success,
displayName = 'Cell',
}: CreateCellProps<CellProps>): React.FC<CellProps> {
/**
* If we're prerendering, render the Cell's Loading component and exit early.
*/
if (global.__REDWOOD__PRERENDERING) {
/**
* Apollo Client's props aren't available here, so 'any'.
*/
return (props) => <Loading {...(props as any)} />
}
function NamedCell(props: React.PropsWithChildren<CellProps>) {
/**
* Right now, Cells don't render `children`.
*/
const { children: _, ...variables } = props
const options = beforeQuery(variables)
// queryRest includes `variables: { ... }`, with any variables returned
// from beforeQuery
const { error, loading, data, ...queryRest } = useQuery(
typeof QUERY === 'function' ? QUERY(options) : QUERY,
options
)
if (error) {
if (Failure) {
return (
<Failure
error={error}
errorCode={error.graphQLErrors?.[0]?.extensions?.['code'] as string}
{...props}
updating={loading}
{...queryRest}
/>
)
} else {
throw error
}
} else if (data) {
const afterQueryData = afterQuery(data)
if (isEmpty(data, { isDataEmpty }) && Empty) {
return (
<Empty
{...props}
{...afterQueryData}
updating={loading}
{...queryRest}
/>
)
} else {
return (
<Success
{...props}
{...afterQueryData}
updating={loading}
{...queryRest}
/>
)
}
} else if (loading) {
return <Loading {...{ ...queryRest, ...props }} />
} else {
/**
* There really shouldn't be an `else` here, but like any piece of software, GraphQL clients have bugs.
* If there's no `error` and there's no `data` and we're not `loading`, something's wrong. Most likely with the cache.
*
* @see {@link https://github.com/redwoodjs/redwood/issues/2473#issuecomment-971864604}
*/
console.warn(
`If you're using Apollo Client, check for its debug logs here in the console, which may help explain the error.`
)
throw new Error(
'Cannot render Cell: reached an unexpected state where the query succeeded but `data` is `null`. If this happened in Storybook, your query could be missing fields; otherwise this is most likely a GraphQL caching bug. Note that adding an `id` field to all the fields on your query may fix the issue.'
)
}
}
NamedCell.displayName = displayName
return NamedCell
}