@planningcenter/chat-react-native 3.37.0-rc.2 → 3.37.0-rc.4
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/build/components/conversation/message.d.ts.map +1 -1
- package/build/components/conversation/message.js +3 -3
- package/build/components/conversation/message.js.map +1 -1
- package/build/components/index.d.ts +2 -0
- package/build/components/index.d.ts.map +1 -1
- package/build/components/index.js +2 -0
- package/build/components/index.js.map +1 -1
- package/build/components/page/component_error_boundary.d.ts +4 -0
- package/build/components/page/component_error_boundary.d.ts.map +1 -0
- package/build/components/page/component_error_boundary.js +8 -0
- package/build/components/page/component_error_boundary.js.map +1 -0
- package/build/components/page/error_boundary.d.ts +13 -10
- package/build/components/page/error_boundary.d.ts.map +1 -1
- package/build/components/page/error_boundary.js +20 -90
- package/build/components/page/error_boundary.js.map +1 -1
- package/build/components/page/page_error_boundary.d.ts +4 -0
- package/build/components/page/page_error_boundary.d.ts.map +1 -0
- package/build/components/page/page_error_boundary.js +80 -0
- package/build/components/page/page_error_boundary.js.map +1 -0
- package/build/navigation/screenLayout.d.ts.map +1 -1
- package/build/navigation/screenLayout.js +5 -3
- package/build/navigation/screenLayout.js.map +1 -1
- package/build/utils/client/instrumented_fetch.d.ts +2 -0
- package/build/utils/client/instrumented_fetch.d.ts.map +1 -0
- package/build/utils/client/instrumented_fetch.js +64 -0
- package/build/utils/client/instrumented_fetch.js.map +1 -0
- package/build/utils/client/request_helpers.d.ts.map +1 -1
- package/build/utils/client/request_helpers.js +2 -1
- package/build/utils/client/request_helpers.js.map +1 -1
- package/build/utils/native_adapters/log.d.ts +1 -1
- package/build/utils/native_adapters/log.d.ts.map +1 -1
- package/build/utils/native_adapters/log.js.map +1 -1
- package/package.json +2 -2
- package/src/components/conversation/message.tsx +6 -4
- package/src/components/index.tsx +2 -0
- package/src/components/page/__tests__/component_error_boundary.test.tsx +46 -0
- package/src/components/page/__tests__/error_boundary.test.tsx +93 -0
- package/src/components/page/__tests__/page_error_boundary.test.tsx +77 -0
- package/src/components/page/component_error_boundary.tsx +13 -0
- package/src/components/page/error_boundary.tsx +34 -118
- package/src/components/page/page_error_boundary.tsx +112 -0
- package/src/navigation/screenLayout.tsx +6 -3
- package/src/utils/client/__tests__/instrumented_fetch.test.ts +84 -0
- package/src/utils/client/instrumented_fetch.ts +69 -0
- package/src/utils/client/request_helpers.ts +2 -1
- package/src/utils/native_adapters/log.ts +1 -1
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useNavigation } from '@react-navigation/native'
|
|
2
|
+
import { useQueryErrorResetBoundary } from '@tanstack/react-query'
|
|
3
|
+
import React, { PropsWithChildren, useCallback, useEffect, useMemo } from 'react'
|
|
4
|
+
import { onAuthRefresh } from '../../utils/auth_events'
|
|
5
|
+
import { ResponseError } from '../../utils/response_error'
|
|
6
|
+
import BlankState from '../primitive/blank_state_primitive'
|
|
7
|
+
import { ErrorBoundary, ErrorBoundaryFallback, ErrorBoundaryProps } from './error_boundary'
|
|
8
|
+
|
|
9
|
+
const renderPageFallback: ErrorBoundaryFallback = (error, reset) => (
|
|
10
|
+
<ErrorView error={error} reset={reset} />
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
export function PageErrorBoundary({ children, ...props }: PropsWithChildren<ErrorBoundaryProps>) {
|
|
14
|
+
return (
|
|
15
|
+
<ErrorBoundary scope="screen" fallback={renderPageFallback} {...props}>
|
|
16
|
+
{children}
|
|
17
|
+
</ErrorBoundary>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function ErrorView({ error, reset }: { error: Error; reset: () => void }) {
|
|
22
|
+
const { reset: resetQueries } = useQueryErrorResetBoundary()
|
|
23
|
+
|
|
24
|
+
const handleReset = useCallback(() => {
|
|
25
|
+
resetQueries()
|
|
26
|
+
reset()
|
|
27
|
+
}, [resetQueries, reset])
|
|
28
|
+
|
|
29
|
+
useEffect(() => handleReset, [handleReset])
|
|
30
|
+
|
|
31
|
+
if (error instanceof ResponseError) {
|
|
32
|
+
return <ResponseErrorView response={error.response} onReset={handleReset} />
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (isNetworkError(error)) {
|
|
36
|
+
return (
|
|
37
|
+
<ErrorContent
|
|
38
|
+
heading={'Problem connecting!'}
|
|
39
|
+
body={'Check your internet connection and try again.'}
|
|
40
|
+
/>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return <ErrorContent heading={'Oops!'} body={'Something unexpected happened.'} />
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isNetworkError(error: Error) {
|
|
48
|
+
const networkFailedMessages = [
|
|
49
|
+
'Network request failed',
|
|
50
|
+
'Network request timed out',
|
|
51
|
+
'Failed to fetch',
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
return new RegExp(networkFailedMessages.join('|'), 'i').test(error.message)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function ResponseErrorView({ response, onReset }: { response: Response; onReset: () => void }) {
|
|
58
|
+
const { status } = response
|
|
59
|
+
|
|
60
|
+
const heading = useMemo(() => {
|
|
61
|
+
switch (status) {
|
|
62
|
+
case 403:
|
|
63
|
+
return 'Permission required'
|
|
64
|
+
case 404:
|
|
65
|
+
return 'Content not found'
|
|
66
|
+
default:
|
|
67
|
+
return 'Oops!'
|
|
68
|
+
}
|
|
69
|
+
}, [status])
|
|
70
|
+
|
|
71
|
+
const body = useMemo(() => {
|
|
72
|
+
switch (status) {
|
|
73
|
+
case 403:
|
|
74
|
+
return 'Contact your administrator for access.'
|
|
75
|
+
case 404:
|
|
76
|
+
return 'If you believe something should be here, please reach out to your administrator.'
|
|
77
|
+
default:
|
|
78
|
+
return 'Something unexpected happened.'
|
|
79
|
+
}
|
|
80
|
+
}, [status])
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (status !== 401) return
|
|
84
|
+
|
|
85
|
+
return onAuthRefresh(onReset)
|
|
86
|
+
}, [status, onReset])
|
|
87
|
+
|
|
88
|
+
return <ErrorContent heading={heading} body={body} />
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function ErrorContent({ heading, body }: { heading: string; body: string }) {
|
|
92
|
+
const navigation = useNavigation()
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<BlankState.Root>
|
|
96
|
+
<BlankState.Imagery name="people.noTextMessage" />
|
|
97
|
+
<BlankState.Content>
|
|
98
|
+
<BlankState.Heading>{heading}</BlankState.Heading>
|
|
99
|
+
<BlankState.Text>{body}</BlankState.Text>
|
|
100
|
+
</BlankState.Content>
|
|
101
|
+
<BlankState.Button
|
|
102
|
+
title="Go back"
|
|
103
|
+
onPress={navigation.goBack}
|
|
104
|
+
size="md"
|
|
105
|
+
accessibilityRole="link"
|
|
106
|
+
/>
|
|
107
|
+
<BlankState.TextButton onPress={() => navigation.navigate('BugReport')}>
|
|
108
|
+
Report a bug
|
|
109
|
+
</BlankState.TextButton>
|
|
110
|
+
</BlankState.Root>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
@@ -1,14 +1,17 @@
|
|
|
1
|
+
import { useRoute } from '@react-navigation/native'
|
|
1
2
|
import React from 'react'
|
|
2
3
|
import { Suspense } from 'react'
|
|
3
|
-
import ErrorBoundary from '../components/page/error_boundary'
|
|
4
4
|
import { DefaultLoading } from '../components/page/loading'
|
|
5
|
+
import { PageErrorBoundary } from '../components/page/page_error_boundary'
|
|
5
6
|
import { ChatAccessGate } from './chat_access_gate'
|
|
6
7
|
|
|
7
8
|
export function ScreenLayout({ children }: { children: React.ReactElement }) {
|
|
9
|
+
const route = useRoute()
|
|
10
|
+
|
|
8
11
|
return (
|
|
9
|
-
<
|
|
12
|
+
<PageErrorBoundary screenName={route.name}>
|
|
10
13
|
<Suspense fallback={<DefaultLoading />}>{children}</Suspense>
|
|
11
|
-
</
|
|
14
|
+
</PageErrorBoundary>
|
|
12
15
|
)
|
|
13
16
|
}
|
|
14
17
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Log } from '../../native_adapters/configuration'
|
|
2
|
+
import { instrumentedFetch } from '../instrumented_fetch'
|
|
3
|
+
|
|
4
|
+
const buildResponse = (status: number) => new Response('', { status })
|
|
5
|
+
|
|
6
|
+
describe('instrumentedFetch', () => {
|
|
7
|
+
let reportError: jest.SpyInstance
|
|
8
|
+
let fetchSpy: jest.SpyInstance
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
reportError = jest.spyOn(Log, 'reportError').mockImplementation(() => {})
|
|
12
|
+
fetchSpy = jest.spyOn(globalThis, 'fetch')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
reportError.mockRestore()
|
|
17
|
+
fetchSpy.mockRestore()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('passes successful responses through without reporting', async () => {
|
|
21
|
+
const response = buildResponse(200)
|
|
22
|
+
fetchSpy.mockResolvedValueOnce(response)
|
|
23
|
+
|
|
24
|
+
const result = await instrumentedFetch('https://api.example.com/conversations', {
|
|
25
|
+
method: 'GET',
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
expect(result).toBe(response)
|
|
29
|
+
expect(reportError).not.toHaveBeenCalled()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it.each([400, 422, 500])(
|
|
33
|
+
'reports %d with method, status, and templated path tags',
|
|
34
|
+
async status => {
|
|
35
|
+
fetchSpy.mockResolvedValueOnce(buildResponse(status))
|
|
36
|
+
|
|
37
|
+
await instrumentedFetch('https://api.example.com/conversations/123/messages', {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
expect(reportError).toHaveBeenCalledTimes(1)
|
|
42
|
+
const [error, context] = reportError.mock.calls[0]
|
|
43
|
+
expect(error.name).toBe(`HTTPError${status}`)
|
|
44
|
+
expect(error.message).toBe(`HTTP ${status} POST /conversations/:id/messages`)
|
|
45
|
+
expect(context).toMatchObject({
|
|
46
|
+
scope: 'http',
|
|
47
|
+
tags: {
|
|
48
|
+
team: 'chat',
|
|
49
|
+
package: 'chat-react-native',
|
|
50
|
+
'http.status': String(status),
|
|
51
|
+
'http.method': 'POST',
|
|
52
|
+
'http.path': '/conversations/:id/messages',
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
it.each([401, 403, 404])('does not report %d (expected user states)', async status => {
|
|
59
|
+
fetchSpy.mockResolvedValueOnce(buildResponse(status))
|
|
60
|
+
|
|
61
|
+
await instrumentedFetch('https://api.example.com/x', { method: 'GET' })
|
|
62
|
+
|
|
63
|
+
expect(reportError).not.toHaveBeenCalled()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('reports network failures with http.error=network', async () => {
|
|
67
|
+
const networkError = new TypeError('Network request failed')
|
|
68
|
+
fetchSpy.mockRejectedValueOnce(networkError)
|
|
69
|
+
|
|
70
|
+
await expect(
|
|
71
|
+
instrumentedFetch('https://api.example.com/conversations/123', { method: 'GET' })
|
|
72
|
+
).rejects.toBe(networkError)
|
|
73
|
+
|
|
74
|
+
expect(reportError).toHaveBeenCalledTimes(1)
|
|
75
|
+
const [error, context] = reportError.mock.calls[0]
|
|
76
|
+
expect(error.name).toBe('NetworkError')
|
|
77
|
+
expect(error.message).toContain('Network failure GET /conversations/:id')
|
|
78
|
+
expect(context.tags).toMatchObject({
|
|
79
|
+
'http.method': 'GET',
|
|
80
|
+
'http.path': '/conversations/:id',
|
|
81
|
+
'http.error': 'network',
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Log } from '../native_adapters/configuration'
|
|
2
|
+
|
|
3
|
+
const SHARED_TAGS = {
|
|
4
|
+
team: 'chat',
|
|
5
|
+
package: 'chat-react-native',
|
|
6
|
+
} as const
|
|
7
|
+
|
|
8
|
+
const SKIPPED_STATUSES = [401, 403, 404]
|
|
9
|
+
|
|
10
|
+
export async function instrumentedFetch(url: string, init: RequestInit): Promise<Response> {
|
|
11
|
+
const method = init.method ?? 'GET'
|
|
12
|
+
try {
|
|
13
|
+
const response = await fetch(url, init)
|
|
14
|
+
if (!response.ok) reportHttpError(response, method, url)
|
|
15
|
+
return response
|
|
16
|
+
} catch (networkError) {
|
|
17
|
+
reportNetworkError(networkError as Error, method, url)
|
|
18
|
+
throw networkError
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function reportHttpError(response: Response, method: string, url: string) {
|
|
23
|
+
const { status } = response
|
|
24
|
+
if (SKIPPED_STATUSES.includes(status)) return
|
|
25
|
+
|
|
26
|
+
const path = templatePath(url)
|
|
27
|
+
const error = new Error(`HTTP ${status} ${method} ${path}`)
|
|
28
|
+
error.name = `HTTPError${status}`
|
|
29
|
+
|
|
30
|
+
Log.reportError(error, {
|
|
31
|
+
scope: 'http',
|
|
32
|
+
tags: {
|
|
33
|
+
...SHARED_TAGS,
|
|
34
|
+
'http.status': String(status),
|
|
35
|
+
'http.method': method,
|
|
36
|
+
'http.path': path,
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function reportNetworkError(networkError: Error, method: string, url: string) {
|
|
42
|
+
const path = templatePath(url)
|
|
43
|
+
const error = new Error(`Network failure ${method} ${path}: ${networkError.message}`)
|
|
44
|
+
error.name = 'NetworkError'
|
|
45
|
+
|
|
46
|
+
Log.reportError(error, {
|
|
47
|
+
scope: 'http',
|
|
48
|
+
tags: {
|
|
49
|
+
...SHARED_TAGS,
|
|
50
|
+
'http.method': method,
|
|
51
|
+
'http.path': path,
|
|
52
|
+
'http.error': 'network',
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function templatePath(url: string): string {
|
|
58
|
+
let path: string
|
|
59
|
+
try {
|
|
60
|
+
path = new URL(url).pathname
|
|
61
|
+
} catch {
|
|
62
|
+
path = url
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return path
|
|
66
|
+
.split('/')
|
|
67
|
+
.map(segment => (/^\d+$/.test(segment) ? ':id' : segment))
|
|
68
|
+
.join('/')
|
|
69
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import _ from 'lodash'
|
|
2
2
|
import URI from 'urijs'
|
|
3
|
+
import { instrumentedFetch } from './instrumented_fetch'
|
|
3
4
|
import { transformRequestData } from './transform_request_data'
|
|
4
5
|
import transformResponse from './transform_response'
|
|
5
6
|
import { Accumulator, GetRequest, PostRequest, RequestData, WalkRequest } from './types'
|
|
@@ -32,7 +33,7 @@ export const makeRequest = ({ action = 'GET', url, data = {}, headers = {} }: Ma
|
|
|
32
33
|
|
|
33
34
|
const body = ['POST', 'PATCH'].includes(action) ? JSON.stringify(data) : undefined
|
|
34
35
|
|
|
35
|
-
return
|
|
36
|
+
return instrumentedFetch(decodeURIComponent(uri.toString()), {
|
|
36
37
|
method: action,
|
|
37
38
|
headers,
|
|
38
39
|
body,
|