@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.
Files changed (46) hide show
  1. package/build/components/conversation/message.d.ts.map +1 -1
  2. package/build/components/conversation/message.js +3 -3
  3. package/build/components/conversation/message.js.map +1 -1
  4. package/build/components/index.d.ts +2 -0
  5. package/build/components/index.d.ts.map +1 -1
  6. package/build/components/index.js +2 -0
  7. package/build/components/index.js.map +1 -1
  8. package/build/components/page/component_error_boundary.d.ts +4 -0
  9. package/build/components/page/component_error_boundary.d.ts.map +1 -0
  10. package/build/components/page/component_error_boundary.js +8 -0
  11. package/build/components/page/component_error_boundary.js.map +1 -0
  12. package/build/components/page/error_boundary.d.ts +13 -10
  13. package/build/components/page/error_boundary.d.ts.map +1 -1
  14. package/build/components/page/error_boundary.js +20 -90
  15. package/build/components/page/error_boundary.js.map +1 -1
  16. package/build/components/page/page_error_boundary.d.ts +4 -0
  17. package/build/components/page/page_error_boundary.d.ts.map +1 -0
  18. package/build/components/page/page_error_boundary.js +80 -0
  19. package/build/components/page/page_error_boundary.js.map +1 -0
  20. package/build/navigation/screenLayout.d.ts.map +1 -1
  21. package/build/navigation/screenLayout.js +5 -3
  22. package/build/navigation/screenLayout.js.map +1 -1
  23. package/build/utils/client/instrumented_fetch.d.ts +2 -0
  24. package/build/utils/client/instrumented_fetch.d.ts.map +1 -0
  25. package/build/utils/client/instrumented_fetch.js +64 -0
  26. package/build/utils/client/instrumented_fetch.js.map +1 -0
  27. package/build/utils/client/request_helpers.d.ts.map +1 -1
  28. package/build/utils/client/request_helpers.js +2 -1
  29. package/build/utils/client/request_helpers.js.map +1 -1
  30. package/build/utils/native_adapters/log.d.ts +1 -1
  31. package/build/utils/native_adapters/log.d.ts.map +1 -1
  32. package/build/utils/native_adapters/log.js.map +1 -1
  33. package/package.json +2 -2
  34. package/src/components/conversation/message.tsx +6 -4
  35. package/src/components/index.tsx +2 -0
  36. package/src/components/page/__tests__/component_error_boundary.test.tsx +46 -0
  37. package/src/components/page/__tests__/error_boundary.test.tsx +93 -0
  38. package/src/components/page/__tests__/page_error_boundary.test.tsx +77 -0
  39. package/src/components/page/component_error_boundary.tsx +13 -0
  40. package/src/components/page/error_boundary.tsx +34 -118
  41. package/src/components/page/page_error_boundary.tsx +112 -0
  42. package/src/navigation/screenLayout.tsx +6 -3
  43. package/src/utils/client/__tests__/instrumented_fetch.test.ts +84 -0
  44. package/src/utils/client/instrumented_fetch.ts +69 -0
  45. package/src/utils/client/request_helpers.ts +2 -1
  46. 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
- <ErrorBoundary>
12
+ <PageErrorBoundary screenName={route.name}>
10
13
  <Suspense fallback={<DefaultLoading />}>{children}</Suspense>
11
- </ErrorBoundary>
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 fetch(decodeURIComponent(uri.toString()), {
36
+ return instrumentedFetch(decodeURIComponent(uri.toString()), {
36
37
  method: action,
37
38
  headers,
38
39
  body,
@@ -1,4 +1,4 @@
1
- export type ReportErrorScope = 'screen' | 'message' | 'http'
1
+ export type ReportErrorScope = 'screen' | 'component' | 'http'
2
2
 
3
3
  export type ReportErrorContext = {
4
4
  componentStack?: string