@planningcenter/chat-react-native 4.4.0-rc.1 → 4.4.0-rc.3

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 (39) hide show
  1. package/build/components/conversation/system_message.js +3 -3
  2. package/build/components/conversation/system_message.js.map +1 -1
  3. package/build/hooks/use_product_analytics.d.ts +9 -0
  4. package/build/hooks/use_product_analytics.d.ts.map +1 -1
  5. package/build/hooks/use_product_analytics.js +4 -2
  6. package/build/hooks/use_product_analytics.js.map +1 -1
  7. package/build/hooks/use_search_enabled.d.ts +2 -0
  8. package/build/hooks/use_search_enabled.d.ts.map +1 -0
  9. package/build/hooks/use_search_enabled.js +18 -0
  10. package/build/hooks/use_search_enabled.js.map +1 -0
  11. package/build/screens/conversations/components/search_list_header_component.js +3 -3
  12. package/build/screens/conversations/components/search_list_header_component.js.map +1 -1
  13. package/build/screens/conversations/conversations_screen.d.ts.map +1 -1
  14. package/build/screens/conversations/conversations_screen.js +2 -5
  15. package/build/screens/conversations/conversations_screen.js.map +1 -1
  16. package/build/screens/search/components/conversation_results.d.ts +1 -1
  17. package/build/screens/search/components/conversation_results.d.ts.map +1 -1
  18. package/build/screens/search/components/conversation_results.js +2 -2
  19. package/build/screens/search/components/conversation_results.js.map +1 -1
  20. package/build/screens/search/components/search_results_list.d.ts +7 -2
  21. package/build/screens/search/components/search_results_list.d.ts.map +1 -1
  22. package/build/screens/search/components/search_results_list.js +135 -105
  23. package/build/screens/search/components/search_results_list.js.map +1 -1
  24. package/build/screens/search/search_screen.d.ts.map +1 -1
  25. package/build/screens/search/search_screen.js +69 -31
  26. package/build/screens/search/search_screen.js.map +1 -1
  27. package/package.json +2 -2
  28. package/src/components/conversation/system_message.tsx +3 -3
  29. package/src/hooks/use_product_analytics.ts +3 -1
  30. package/src/hooks/use_search_enabled.ts +9 -0
  31. package/src/screens/conversations/__tests__/conversations_screen.test.tsx +71 -0
  32. package/src/screens/conversations/components/search_list_header_component.tsx +1 -1
  33. package/src/screens/conversations/conversations_screen.tsx +2 -3
  34. package/src/screens/search/__tests__/search_screen.test.tsx +56 -2
  35. package/src/screens/search/components/__tests__/conversation_results.test.tsx +2 -2
  36. package/src/screens/search/components/__tests__/search_results_list.test.tsx +47 -2
  37. package/src/screens/search/components/conversation_results.tsx +7 -3
  38. package/src/screens/search/components/search_results_list.tsx +25 -4
  39. package/src/screens/search/search_screen.tsx +24 -4
@@ -0,0 +1,9 @@
1
+ import { availableFeatures, useFeatures } from './use_features'
2
+
3
+ export function useSearchEnabled() {
4
+ const { featureEnabled } = useFeatures()
5
+
6
+ return (
7
+ featureEnabled(availableFeatures.search) || featureEnabled(availableFeatures.search_rollout)
8
+ )
9
+ }
@@ -0,0 +1,71 @@
1
+ import { QueryClientProvider } from '@tanstack/react-query'
2
+ import { render } from '@testing-library/react-native'
3
+ import React, { Suspense } from 'react'
4
+ import { buildTestQueryClient } from '../../../__utils__/query_client'
5
+ import { ConversationsScreen } from '../conversations_screen'
6
+
7
+ const mockGet = jest.fn()
8
+ jest.mock('../../../hooks/use_api_client', () => ({
9
+ useApiClient: () => ({ chat: { get: mockGet } }),
10
+ }))
11
+ jest.mock('../../../hooks/use_product_analytics', () => ({
12
+ ...jest.requireActual('../../../hooks/use_product_analytics'),
13
+ usePublishProductAnalyticsEvent: () => {},
14
+ }))
15
+ jest.mock('../../../contexts/conversations_context', () => ({
16
+ ConversationsContextProvider: ({ children }: { children: React.ReactNode }) => children,
17
+ }))
18
+ jest.mock('../../../components', () => ({
19
+ Conversations: ({ ListHeaderComponent }: { ListHeaderComponent: React.ComponentType }) => (
20
+ <ListHeaderComponent />
21
+ ),
22
+ }))
23
+ jest.mock('../components/list_header_component', () => {
24
+ const { Text } = require('react-native')
25
+ return { ListHeaderComponent: () => <Text>Default header</Text> }
26
+ })
27
+ jest.mock('../components/search_list_header_component', () => {
28
+ const { Text } = require('react-native')
29
+ return { SearchListHeaderComponent: () => <Text>Search header</Text> }
30
+ })
31
+
32
+ const renderWithFeatures = (enabledFeatures: string[]) => {
33
+ mockGet.mockResolvedValue({
34
+ data: enabledFeatures.map((name, index) => ({
35
+ type: 'Feature',
36
+ id: String(index + 1),
37
+ name,
38
+ enabled: true,
39
+ })),
40
+ links: {},
41
+ meta: { count: enabledFeatures.length, totalCount: enabledFeatures.length },
42
+ })
43
+
44
+ return render(
45
+ <QueryClientProvider client={buildTestQueryClient()}>
46
+ <Suspense fallback={null}>
47
+ <ConversationsScreen route={{ params: {} }} />
48
+ </Suspense>
49
+ </QueryClientProvider>
50
+ )
51
+ }
52
+
53
+ describe('ConversationsScreen header gating', () => {
54
+ it('shows the search header with only the QA flag on', async () => {
55
+ const view = renderWithFeatures(['QA_MOBILE_search'])
56
+
57
+ expect(await view.findByText('Search header')).toBeTruthy()
58
+ })
59
+
60
+ it('shows the search header with only the rollout flag on', async () => {
61
+ const view = renderWithFeatures(['ROLLOUT_MOBILE_search'])
62
+
63
+ expect(await view.findByText('Search header')).toBeTruthy()
64
+ })
65
+
66
+ it('shows the default header with neither flag on', async () => {
67
+ const view = renderWithFeatures(['ROLLOUT_WEB_search'])
68
+
69
+ expect(await view.findByText('Default header')).toBeTruthy()
70
+ })
71
+ })
@@ -264,7 +264,7 @@ const useStyles = ({
264
264
  return StyleSheet.create({
265
265
  listHeader: {
266
266
  borderBottomWidth: 1,
267
- borderBottomColor: theme.colors.statusErrorBorder,
267
+ borderBottomColor: theme.colors.fillColorNeutral050Base,
268
268
  paddingBottom: 16,
269
269
  gap: 16,
270
270
  },
@@ -3,8 +3,8 @@ import React from 'react'
3
3
  import { StyleSheet, View } from 'react-native'
4
4
  import { Conversations } from '../../components'
5
5
  import { ConversationsContextProvider } from '../../contexts/conversations_context'
6
- import { availableFeatures, useFeatures } from '../../hooks/use_features'
7
6
  import { analyticsEvents, usePublishProductAnalyticsEvent } from '../../hooks/use_product_analytics'
7
+ import { useSearchEnabled } from '../../hooks/use_search_enabled'
8
8
  import { AppName } from '../../types/resources/app_name'
9
9
  import { GraphId } from '../../types/resources/group_resource'
10
10
  import { ListHeaderComponent } from './components/list_header_component'
@@ -19,8 +19,7 @@ export type ConversationsScreenProps = StaticScreenProps<{
19
19
 
20
20
  export function ConversationsScreen({ route }: ConversationsScreenProps) {
21
21
  const styles = useStyles()
22
- const { featureEnabled } = useFeatures()
23
- const searchEnabled = featureEnabled(availableFeatures.search)
22
+ const searchEnabled = useSearchEnabled()
24
23
  const Header = searchEnabled ? SearchListHeaderComponent : ListHeaderComponent
25
24
 
26
25
  usePublishProductAnalyticsEvent(analyticsEvents.conversation_index_opened)
@@ -18,6 +18,11 @@ const mockUseSearchResults = jest.fn<ReturnType<typeof useSearchResults>, [strin
18
18
  jest.mock('../../../hooks/use_search_results', () => ({
19
19
  useSearchResults: (term: string) => mockUseSearchResults(term),
20
20
  }))
21
+ const mockPublishEvent = jest.fn()
22
+ jest.mock('../../../hooks/use_product_analytics', () => ({
23
+ ...jest.requireActual('../../../hooks/use_product_analytics'),
24
+ useProductAnalytics: () => ({ publishEvent: mockPublishEvent }),
25
+ }))
21
26
  jest.mock('../../../components/display/keyboard_view', () => ({
22
27
  ...jest.requireActual('../../../components/display/keyboard_view'),
23
28
  KeyboardView: ({ children }: { children: React.ReactNode }) => children,
@@ -47,9 +52,17 @@ const messageResult: MessageSearchResultResource = {
47
52
  },
48
53
  }
49
54
 
55
+ const secondMessageResult: MessageSearchResultResource = {
56
+ ...messageResult,
57
+ id: '01JSECONDULID',
58
+ title: 'Youth',
59
+ url: '/conversations/9/messages/01JSECONDULID',
60
+ extra: { ...messageResult.extra, conversationId: 9, authorName: 'Bob' },
61
+ }
62
+
50
63
  const renderWithResults = () => {
51
64
  mockUseSearchResults.mockReturnValue({
52
- results: [conversationResult, messageResult],
65
+ results: [conversationResult, messageResult, secondMessageResult],
53
66
  failedTypes: [],
54
67
  isPending: false,
55
68
  isError: false,
@@ -59,7 +72,9 @@ const renderWithResults = () => {
59
72
  isFetchingNextPage: false,
60
73
  })
61
74
  const view = render(<SearchScreen />)
62
- fireEvent.changeText(view.getByLabelText(inputLabel), 'budget')
75
+ const input = view.getByLabelText(inputLabel)
76
+ fireEvent.changeText(input, 'bud')
77
+ fireEvent.changeText(input, 'budget')
63
78
  act(() => jest.advanceTimersByTime(200))
64
79
  return view
65
80
  }
@@ -67,6 +82,7 @@ const renderWithResults = () => {
67
82
  beforeEach(() => {
68
83
  jest.useFakeTimers()
69
84
  mockNavigate.mockReset()
85
+ mockPublishEvent.mockReset()
70
86
  mockUseSearchResults.mockReset().mockReturnValue({
71
87
  results: [],
72
88
  failedTypes: [],
@@ -137,4 +153,42 @@ describe('SearchScreen', () => {
137
153
  title: 'Finance',
138
154
  })
139
155
  })
156
+
157
+ it('publishes one search event per settled term with result counts, never the term', () => {
158
+ renderWithResults()
159
+
160
+ expect(mockPublishEvent).toHaveBeenCalledTimes(1)
161
+ expect(mockPublishEvent).toHaveBeenCalledWith('chat.mobile.search.performed', {
162
+ term_length: 6,
163
+ conversation_result_count: 1,
164
+ message_result_count: 2,
165
+ })
166
+ expect(JSON.stringify(mockPublishEvent.mock.calls)).not.toContain('budget')
167
+ })
168
+
169
+ it('does not publish a retyped term whose results are already cached', () => {
170
+ const view = renderWithResults()
171
+ const input = view.getByLabelText(inputLabel)
172
+
173
+ fireEvent.changeText(input, 'budge')
174
+ act(() => jest.advanceTimersByTime(200))
175
+ fireEvent.changeText(input, 'budget')
176
+ act(() => jest.advanceTimersByTime(200))
177
+
178
+ const performed = mockPublishEvent.mock.calls.filter(
179
+ ([name]) => name === 'chat.mobile.search.performed'
180
+ )
181
+ expect(performed.map(([, meta]) => meta.term_length)).toEqual([6, 5])
182
+ })
183
+
184
+ it('publishes the opened result type and its position within its section', () => {
185
+ const view = renderWithResults()
186
+
187
+ fireEvent.press(view.getByLabelText(/^Youth, Bob: budget/))
188
+
189
+ expect(mockPublishEvent).toHaveBeenCalledWith('chat.mobile.search.result.opened', {
190
+ result_type: 'message',
191
+ position: 1,
192
+ })
193
+ })
140
194
  })
@@ -43,13 +43,13 @@ describe('ConversationResults', () => {
43
43
  expect(view.getByText('View less')).toBeTruthy()
44
44
  })
45
45
 
46
- it('passes the pressed result to onSelect', () => {
46
+ it('passes the pressed result and its position to onSelect', () => {
47
47
  const onSelect = jest.fn()
48
48
  const results = buildResults(2)
49
49
  const view = render(<ConversationResults results={results} onSelect={onSelect} />)
50
50
 
51
51
  fireEvent.press(view.getByText('Conversation 2'))
52
52
 
53
- expect(onSelect).toHaveBeenCalledWith(results[1])
53
+ expect(onSelect).toHaveBeenCalledWith(results[1], 1)
54
54
  })
55
55
  })
@@ -49,9 +49,15 @@ const messageResult = (id: string): MessageSearchResultResource => ({
49
49
  },
50
50
  })
51
51
 
52
- const renderList = (state: Partial<SearchState>) => {
52
+ const renderList = (state: Partial<SearchState>, onSettled = jest.fn()) => {
53
53
  mockUseSearchResults.mockReturnValue(searchState(state))
54
- return render(<SearchResultsList term="potluck" onSelect={jest.fn()} />)
54
+ const list = () => <SearchResultsList term="potluck" onSelect={jest.fn()} onSettled={onSettled} />
55
+ const view = render(list())
56
+ const rerenderWith = (next: Partial<SearchState>) => {
57
+ mockUseSearchResults.mockReturnValue(searchState(next))
58
+ view.rerender(list())
59
+ }
60
+ return { ...view, rerenderWith }
55
61
  }
56
62
 
57
63
  describe('SearchResultsList', () => {
@@ -119,6 +125,45 @@ describe('SearchResultsList', () => {
119
125
  expect(loadMore).not.toHaveBeenCalled()
120
126
  })
121
127
 
128
+ it('passes a message result and its position within the section to onSelect', () => {
129
+ const onSelect = jest.fn()
130
+ const results = [conversationResult('1'), messageResult('1'), messageResult('2')]
131
+ mockUseSearchResults.mockReturnValue(searchState({ results }))
132
+ const view = render(
133
+ <SearchResultsList term="potluck" onSelect={onSelect} onSettled={jest.fn()} />
134
+ )
135
+
136
+ fireEvent.press(view.getByLabelText(/^Message 2/))
137
+
138
+ expect(onSelect).toHaveBeenCalledWith(results[2], 1)
139
+ })
140
+
141
+ it('reports the settled term and counts once, not while pending or after paging', () => {
142
+ const onSettled = jest.fn()
143
+ const view = renderList({ isPending: true }, onSettled)
144
+ expect(onSettled).not.toHaveBeenCalled()
145
+
146
+ const results = [conversationResult('1'), messageResult('1'), messageResult('2')]
147
+ view.rerenderWith({ results })
148
+
149
+ expect(onSettled).toHaveBeenCalledTimes(1)
150
+ expect(onSettled).toHaveBeenCalledWith('potluck', {
151
+ conversationResultCount: 1,
152
+ messageResultCount: 2,
153
+ })
154
+
155
+ view.rerenderWith({ results: [...results, messageResult('3')] })
156
+
157
+ expect(onSettled).toHaveBeenCalledTimes(1)
158
+ })
159
+
160
+ it('does not report a failed search', () => {
161
+ const onSettled = jest.fn()
162
+ renderList({ isError: true }, onSettled)
163
+
164
+ expect(onSettled).not.toHaveBeenCalled()
165
+ })
166
+
122
167
  it('offers a retry after a paging failure', () => {
123
168
  const loadMore = jest.fn()
124
169
  const view = renderList({
@@ -8,7 +8,7 @@ const COLLAPSED_COUNT = 5
8
8
 
9
9
  interface ConversationResultsProps {
10
10
  results: ConversationSearchResultResource[]
11
- onSelect: (result: ConversationSearchResultResource) => void
11
+ onSelect: (result: ConversationSearchResultResource, position: number) => void
12
12
  }
13
13
 
14
14
  export function ConversationResults({ results, onSelect }: ConversationResultsProps) {
@@ -31,8 +31,12 @@ export function ConversationResults({ results, onSelect }: ConversationResultsPr
31
31
 
32
32
  return (
33
33
  <ResultsSection title="Conversations" footer={footer}>
34
- {visible.map(result => (
35
- <ConversationResultRow key={result.id} result={result} onPress={() => onSelect(result)} />
34
+ {visible.map((result, index) => (
35
+ <ConversationResultRow
36
+ key={result.id}
37
+ result={result}
38
+ onPress={() => onSelect(result, index)}
39
+ />
36
40
  ))}
37
41
  </ResultsSection>
38
42
  )
@@ -1,4 +1,4 @@
1
- import React from 'react'
1
+ import React, { useEffect, useRef } from 'react'
2
2
  import { FlatList, StyleSheet, View } from 'react-native'
3
3
  import { Banner, Spinner, Text, TextButton } from '../../../components'
4
4
  import { useSearchResults } from '../../../hooks/use_search_results'
@@ -14,12 +14,18 @@ import { NoResultsBlankState } from './search_blank_states'
14
14
 
15
15
  const SEARCH_ERROR_MESSAGE = "Couldn't search right now. Try again."
16
16
 
17
+ export interface SearchResultCounts {
18
+ conversationResultCount: number
19
+ messageResultCount: number
20
+ }
21
+
17
22
  interface SearchResultsListProps {
18
23
  term: string
19
- onSelect: (result: SearchResultResource) => void
24
+ onSelect: (result: SearchResultResource, position: number) => void
25
+ onSettled: (term: string, counts: SearchResultCounts) => void
20
26
  }
21
27
 
22
- export function SearchResultsList({ term, onSelect }: SearchResultsListProps) {
28
+ export function SearchResultsList({ term, onSelect, onSettled }: SearchResultsListProps) {
23
29
  const {
24
30
  results,
25
31
  failedTypes,
@@ -32,6 +38,19 @@ export function SearchResultsList({ term, onSelect }: SearchResultsListProps) {
32
38
  } = useSearchResults(term)
33
39
  const conversationResults = results.filter(isConversationResult)
34
40
  const messageResults = results.filter(isMessageResult)
41
+ const settled = !isPending && !isError
42
+
43
+ const settledTermRef = useRef<string | undefined>(undefined)
44
+
45
+ useEffect(() => {
46
+ if (!settled || settledTermRef.current === term) return
47
+
48
+ settledTermRef.current = term
49
+ onSettled(term, {
50
+ conversationResultCount: conversationResults.length,
51
+ messageResultCount: messageResults.length,
52
+ })
53
+ }, [settled, term, conversationResults.length, messageResults.length, onSettled])
35
54
 
36
55
  return (
37
56
  <FlatList
@@ -40,7 +59,9 @@ export function SearchResultsList({ term, onSelect }: SearchResultsListProps) {
40
59
  keyboardShouldPersistTaps="handled"
41
60
  keyboardDismissMode="on-drag"
42
61
  contentContainerStyle={styles.content}
43
- renderItem={({ item }) => <MessageResultRow result={item} onPress={onSelect} />}
62
+ renderItem={({ item, index }) => (
63
+ <MessageResultRow result={item} onPress={() => onSelect(item, index)} />
64
+ )}
44
65
  ListHeaderComponent={
45
66
  <>
46
67
  {conversationResults.length > 0 && (
@@ -3,7 +3,7 @@ import {
3
3
  NativeStackNavigationOptions,
4
4
  NativeStackScreenProps,
5
5
  } from '@react-navigation/native-stack'
6
- import React, { useState } from 'react'
6
+ import React, { useRef, useState } from 'react'
7
7
  import { Keyboard, StyleSheet, View } from 'react-native'
8
8
  import { KeyboardView } from '../../components'
9
9
  import {
@@ -11,10 +11,11 @@ import {
11
11
  withMinimalBack,
12
12
  } from '../../components/display/platform_modal_header_buttons'
13
13
  import { useDebouncedSearchTerm } from '../../hooks/use_debounced_search_term'
14
+ import { analyticsEvents, useProductAnalytics } from '../../hooks/use_product_analytics'
14
15
  import type { SearchResultResource } from '../../types/resources/search_result'
15
16
  import { SearchPromptBlankState } from './components/search_blank_states'
16
17
  import { SearchField } from './components/search_field'
17
- import { SearchResultsList } from './components/search_results_list'
18
+ import { SearchResultsList, type SearchResultCounts } from './components/search_results_list'
18
19
 
19
20
  export const SearchScreenOptions = ({
20
21
  navigation,
@@ -28,9 +29,23 @@ export function SearchScreen() {
28
29
  const navigation = useNavigation()
29
30
  const [term, setTerm] = useState('')
30
31
  const debouncedTerm = useDebouncedSearchTerm(term)
32
+ const { publishEvent } = useProductAnalytics()
33
+ const publishedTerms = useRef(new Set<string>())
31
34
 
32
- const openResult = (result: SearchResultResource) => {
35
+ const publishSearch = (settledTerm: string, counts: SearchResultCounts) => {
36
+ if (publishedTerms.current.has(settledTerm)) return
37
+
38
+ publishedTerms.current.add(settledTerm)
39
+ publishEvent(analyticsEvents.search_performed, {
40
+ term_length: settledTerm.length,
41
+ conversation_result_count: counts.conversationResultCount,
42
+ message_result_count: counts.messageResultCount,
43
+ })
44
+ }
45
+
46
+ const openResult = (result: SearchResultResource, position: number) => {
33
47
  Keyboard.dismiss()
48
+ publishEvent(analyticsEvents.search_result_opened, { result_type: result.type, position })
34
49
 
35
50
  if (result.type === 'conversation') {
36
51
  navigation.navigate('Conversation', {
@@ -51,7 +66,12 @@ export function SearchScreen() {
51
66
  <View style={styles.container} testID="search-screen">
52
67
  <SearchField term={term} onChange={setTerm} />
53
68
  {debouncedTerm.length > 0 ? (
54
- <SearchResultsList key={debouncedTerm} term={debouncedTerm} onSelect={openResult} />
69
+ <SearchResultsList
70
+ key={debouncedTerm}
71
+ term={debouncedTerm}
72
+ onSelect={openResult}
73
+ onSettled={publishSearch}
74
+ />
55
75
  ) : (
56
76
  <SearchPromptBlankState />
57
77
  )}