@planningcenter/chat-react-native 3.38.0-rc.9 → 3.38.1-rc.0

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 (154) hide show
  1. package/build/components/conversation/jump_to_bottom_button.d.ts +1 -2
  2. package/build/components/conversation/jump_to_bottom_button.d.ts.map +1 -1
  3. package/build/components/conversation/jump_to_bottom_button.js +7 -39
  4. package/build/components/conversation/jump_to_bottom_button.js.map +1 -1
  5. package/build/components/conversation/reply_shadow_message.d.ts +2 -1
  6. package/build/components/conversation/reply_shadow_message.d.ts.map +1 -1
  7. package/build/components/conversation/reply_shadow_message.js.map +1 -1
  8. package/build/contexts/conversation_context.d.ts +1 -8
  9. package/build/contexts/conversation_context.d.ts.map +1 -1
  10. package/build/contexts/conversation_context.js +3 -21
  11. package/build/contexts/conversation_context.js.map +1 -1
  12. package/build/hooks/use_api.d.ts.map +1 -1
  13. package/build/hooks/use_api.js +8 -2
  14. package/build/hooks/use_api.js.map +1 -1
  15. package/build/hooks/use_conversation_messages.d.ts +6 -15
  16. package/build/hooks/use_conversation_messages.d.ts.map +1 -1
  17. package/build/hooks/use_conversation_messages.js +9 -62
  18. package/build/hooks/use_conversation_messages.js.map +1 -1
  19. package/build/hooks/use_conversation_messages_jolt_events.d.ts.map +1 -1
  20. package/build/hooks/use_conversation_messages_jolt_events.js +4 -4
  21. package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
  22. package/build/hooks/use_conversations_actions.d.ts +0 -5
  23. package/build/hooks/use_conversations_actions.d.ts.map +1 -1
  24. package/build/hooks/use_conversations_actions.js +0 -12
  25. package/build/hooks/use_conversations_actions.js.map +1 -1
  26. package/build/hooks/use_features.d.ts +0 -1
  27. package/build/hooks/use_features.d.ts.map +1 -1
  28. package/build/hooks/use_features.js +0 -1
  29. package/build/hooks/use_features.js.map +1 -1
  30. package/build/hooks/use_mark_latest_message_read.d.ts +1 -1
  31. package/build/hooks/use_mark_latest_message_read.d.ts.map +1 -1
  32. package/build/hooks/use_mark_latest_message_read.js +1 -17
  33. package/build/hooks/use_mark_latest_message_read.js.map +1 -1
  34. package/build/hooks/use_suspense_api.d.ts +0 -1
  35. package/build/hooks/use_suspense_api.d.ts.map +1 -1
  36. package/build/hooks/use_suspense_api.js +1 -1
  37. package/build/hooks/use_suspense_api.js.map +1 -1
  38. package/build/screens/conversation_filter_recipients/components/header_row.d.ts.map +1 -1
  39. package/build/screens/conversation_filter_recipients/components/header_row.js +3 -2
  40. package/build/screens/conversation_filter_recipients/components/header_row.js.map +1 -1
  41. package/build/screens/conversation_filter_recipients/hooks/use_flattened_array_of_service_types_with_teams.d.ts.map +1 -1
  42. package/build/screens/conversation_filter_recipients/hooks/use_flattened_array_of_service_types_with_teams.js +47 -18
  43. package/build/screens/conversation_filter_recipients/hooks/use_flattened_array_of_service_types_with_teams.js.map +1 -1
  44. package/build/screens/conversation_filter_recipients/hooks/use_service_types_with_teams.d.ts +2 -1
  45. package/build/screens/conversation_filter_recipients/hooks/use_service_types_with_teams.d.ts.map +1 -1
  46. package/build/screens/conversation_filter_recipients/hooks/use_service_types_with_teams.js +23 -26
  47. package/build/screens/conversation_filter_recipients/hooks/use_service_types_with_teams.js.map +1 -1
  48. package/build/screens/conversation_filter_recipients/types.d.ts +1 -1
  49. package/build/screens/conversation_filter_recipients/types.d.ts.map +1 -1
  50. package/build/screens/conversation_filter_recipients/types.js.map +1 -1
  51. package/build/screens/conversation_screen.d.ts +0 -1
  52. package/build/screens/conversation_screen.d.ts.map +1 -1
  53. package/build/screens/conversation_screen.js +48 -95
  54. package/build/screens/conversation_screen.js.map +1 -1
  55. package/build/screens/conversation_select_recipients/components/recipient_link_row.d.ts +1 -1
  56. package/build/screens/conversation_select_recipients/components/recipient_link_row.d.ts.map +1 -1
  57. package/build/screens/conversation_select_recipients/components/recipient_link_row.js +3 -3
  58. package/build/screens/conversation_select_recipients/components/recipient_link_row.js.map +1 -1
  59. package/build/screens/conversation_select_recipients/components/team_recipient_row.d.ts.map +1 -1
  60. package/build/screens/conversation_select_recipients/components/team_recipient_row.js +1 -1
  61. package/build/screens/conversation_select_recipients/components/team_recipient_row.js.map +1 -1
  62. package/build/utils/cache/messages_cache.d.ts +0 -1
  63. package/build/utils/cache/messages_cache.d.ts.map +1 -1
  64. package/build/utils/cache/messages_cache.js +0 -4
  65. package/build/utils/cache/messages_cache.js.map +1 -1
  66. package/build/utils/group_messages.d.ts +2 -9
  67. package/build/utils/group_messages.d.ts.map +1 -1
  68. package/build/utils/group_messages.js +1 -20
  69. package/build/utils/group_messages.js.map +1 -1
  70. package/package.json +3 -3
  71. package/src/__tests__/hooks/use_api.test.tsx +53 -0
  72. package/src/components/conversation/jump_to_bottom_button.tsx +8 -57
  73. package/src/components/conversation/reply_shadow_message.tsx +1 -1
  74. package/src/contexts/conversation_context.tsx +2 -30
  75. package/src/hooks/use_api.ts +9 -2
  76. package/src/hooks/use_conversation_messages.ts +20 -120
  77. package/src/hooks/use_conversation_messages_jolt_events.ts +3 -4
  78. package/src/hooks/use_conversations_actions.ts +0 -15
  79. package/src/hooks/use_features.ts +0 -1
  80. package/src/hooks/use_mark_latest_message_read.ts +2 -16
  81. package/src/hooks/use_suspense_api.ts +1 -1
  82. package/src/screens/conversation_filter_recipients/components/header_row.tsx +3 -2
  83. package/src/screens/conversation_filter_recipients/hooks/__tests__/use_service_types_with_teams.test.ts +108 -0
  84. package/src/screens/conversation_filter_recipients/hooks/use_flattened_array_of_service_types_with_teams.tsx +46 -19
  85. package/src/screens/conversation_filter_recipients/hooks/use_service_types_with_teams.ts +31 -29
  86. package/src/screens/conversation_filter_recipients/types.tsx +1 -1
  87. package/src/screens/conversation_screen.tsx +76 -184
  88. package/src/screens/conversation_select_recipients/components/recipient_link_row.tsx +6 -4
  89. package/src/screens/conversation_select_recipients/components/team_recipient_row.tsx +2 -1
  90. package/src/utils/__tests__/group_messages.test.ts +0 -71
  91. package/src/utils/cache/messages_cache.ts +0 -5
  92. package/src/utils/group_messages.ts +2 -42
  93. package/build/components/conversation/unread_divider.d.ts +0 -6
  94. package/build/components/conversation/unread_divider.d.ts.map +0 -1
  95. package/build/components/conversation/unread_divider.js +0 -59
  96. package/build/components/conversation/unread_divider.js.map +0 -1
  97. package/build/hooks/use_flat_list_viewability.d.ts +0 -20
  98. package/build/hooks/use_flat_list_viewability.d.ts.map +0 -1
  99. package/build/hooks/use_flat_list_viewability.js +0 -30
  100. package/build/hooks/use_flat_list_viewability.js.map +0 -1
  101. package/build/hooks/use_jump_to_bottom_action.d.ts +0 -9
  102. package/build/hooks/use_jump_to_bottom_action.d.ts.map +0 -1
  103. package/build/hooks/use_jump_to_bottom_action.js +0 -62
  104. package/build/hooks/use_jump_to_bottom_action.js.map +0 -1
  105. package/build/hooks/use_jump_to_unread_anchor.d.ts +0 -20
  106. package/build/hooks/use_jump_to_unread_anchor.d.ts.map +0 -1
  107. package/build/hooks/use_jump_to_unread_anchor.js +0 -53
  108. package/build/hooks/use_jump_to_unread_anchor.js.map +0 -1
  109. package/build/hooks/use_jump_to_unread_gates.d.ts +0 -5
  110. package/build/hooks/use_jump_to_unread_gates.d.ts.map +0 -1
  111. package/build/hooks/use_jump_to_unread_gates.js +0 -10
  112. package/build/hooks/use_jump_to_unread_gates.js.map +0 -1
  113. package/build/hooks/use_scroll_tracking.d.ts +0 -13
  114. package/build/hooks/use_scroll_tracking.d.ts.map +0 -1
  115. package/build/hooks/use_scroll_tracking.js +0 -45
  116. package/build/hooks/use_scroll_tracking.js.map +0 -1
  117. package/build/hooks/use_track_highest_seen_message.d.ts +0 -4
  118. package/build/hooks/use_track_highest_seen_message.d.ts.map +0 -1
  119. package/build/hooks/use_track_highest_seen_message.js +0 -35
  120. package/build/hooks/use_track_highest_seen_message.js.map +0 -1
  121. package/build/utils/conversation_messages.d.ts +0 -10
  122. package/build/utils/conversation_messages.d.ts.map +0 -1
  123. package/build/utils/conversation_messages.js +0 -22
  124. package/build/utils/conversation_messages.js.map +0 -1
  125. package/build/utils/highest_seen_tracker.d.ts +0 -12
  126. package/build/utils/highest_seen_tracker.d.ts.map +0 -1
  127. package/build/utils/highest_seen_tracker.js +0 -37
  128. package/build/utils/highest_seen_tracker.js.map +0 -1
  129. package/build/utils/message_viewability.d.ts +0 -24
  130. package/build/utils/message_viewability.d.ts.map +0 -1
  131. package/build/utils/message_viewability.js +0 -29
  132. package/build/utils/message_viewability.js.map +0 -1
  133. package/build/utils/unread_divider_helpers.d.ts +0 -18
  134. package/build/utils/unread_divider_helpers.d.ts.map +0 -1
  135. package/build/utils/unread_divider_helpers.js +0 -13
  136. package/build/utils/unread_divider_helpers.js.map +0 -1
  137. package/src/__tests__/hooks/use_conversation_messages.test.tsx +0 -109
  138. package/src/__tests__/hooks/use_mark_latest_message_read.test.tsx +0 -154
  139. package/src/__tests__/utils/cache/messages_cache.test.ts +0 -54
  140. package/src/components/conversation/unread_divider.tsx +0 -90
  141. package/src/hooks/use_flat_list_viewability.ts +0 -50
  142. package/src/hooks/use_jump_to_bottom_action.ts +0 -75
  143. package/src/hooks/use_jump_to_unread_anchor.ts +0 -68
  144. package/src/hooks/use_jump_to_unread_gates.ts +0 -10
  145. package/src/hooks/use_scroll_tracking.ts +0 -64
  146. package/src/hooks/use_track_highest_seen_message.ts +0 -43
  147. package/src/utils/__tests__/conversation_messages.test.ts +0 -105
  148. package/src/utils/__tests__/highest_seen_tracker.test.ts +0 -82
  149. package/src/utils/__tests__/message_viewability.test.ts +0 -168
  150. package/src/utils/__tests__/unread_divider_helpers.test.ts +0 -85
  151. package/src/utils/conversation_messages.ts +0 -37
  152. package/src/utils/highest_seen_tracker.ts +0 -42
  153. package/src/utils/message_viewability.ts +0 -49
  154. package/src/utils/unread_divider_helpers.ts +0 -25
@@ -12,7 +12,6 @@ import {
12
12
  updateCacheWithIndividualMessage,
13
13
  updateCacheWithReaction,
14
14
  getThreadedMessagesQueryKey,
15
- hasUnloadedNewerPages,
16
15
  } from '../utils/cache/messages_cache'
17
16
  import { transformMessageEventDataToMessageResource } from '../utils/jolt/transform_message_event_data_to_message_resource'
18
17
  import { completeMessageCreationTracking } from '../utils/performance_tracking'
@@ -53,10 +52,10 @@ export function useConversationMessagesJoltEvents({ conversationId }: Props) {
53
52
  }
54
53
  }
55
54
 
56
- if (e.event === 'message.updated' || !hasUnloadedNewerPages(queryClient, messagesQueryKey)) {
57
- updateCacheWithMessage(queryClient, messagesQueryKey, message, e.event)
58
- }
55
+ // Update the main conversation cache
56
+ updateCacheWithMessage(queryClient, messagesQueryKey, message, e.event)
59
57
 
58
+ // If message has a reply_root_id, also update the threaded cache
60
59
  if (data.reply_root_id) {
61
60
  const threadedMessagesQueryKey = getThreadedMessagesQueryKey(
62
61
  conversationId,
@@ -98,21 +98,6 @@ export const useConversationsMute = ({ conversation }: { conversation: Conversat
98
98
  }
99
99
  }
100
100
 
101
- export const useConversationsMarkReadUpTo = ({ conversationId }: { conversationId: number }) => {
102
- const apiClient = useApiClient()
103
-
104
- return useMutation({
105
- mutationKey: ['markReadUpTo', conversationId],
106
- mutationFn: async ({ sortKey }: { sortKey: string }) =>
107
- apiClient.chat.post({
108
- url: `/me/conversations/${conversationId}/mark_read_up_to`,
109
- data: {
110
- data: { type: 'Conversation', attributes: { sort_key: sortKey } },
111
- },
112
- }),
113
- })
114
- }
115
-
116
101
  export const useMarkAllRead = () => {
117
102
  const apiClient = useApiClient()
118
103
  const { args } = useConversationsContext()
@@ -40,7 +40,6 @@ export const availableFeatures = {
40
40
  message_reporting: 'ROLLOUT_MOBILE_message_reporting',
41
41
  granular_notifications_ui: 'ROLLOUT_granular_notification_preferences_ui',
42
42
  custom_conversation_avatars: 'ROLLOUT_custom_conversation_avatars',
43
- jump_to_unread: 'ROLLOUT_jump_to_unread',
44
43
  conversation_safety_lock: 'ROLLOUT_conversation_safety_lock',
45
44
  video_moderation: 'ROLLOUT_MOBILE_video_moderation',
46
45
  } as const satisfies Record<string, `ROLLOUT_${string}`>
@@ -1,19 +1,15 @@
1
1
  import { debounce } from 'lodash'
2
2
  import { useEffect, useMemo, useRef } from 'react'
3
- import { useConversationContext } from '../contexts/conversation_context'
4
3
  import { ConversationResource, MessageResource } from '../types'
5
4
  import { useAppState } from './use_app_state'
6
5
  import { useConversationsMarkRead } from './use_conversations_actions'
7
- import { useJumpToUnreadGates } from './use_jump_to_unread_gates'
8
6
 
9
7
  interface Props {
10
8
  conversation: ConversationResource
11
- messages?: MessageResource[]
9
+ messages: MessageResource[]
12
10
  }
13
11
 
14
12
  export function useMarkLatestMessageRead({ conversation }: Props) {
15
- const { jumpToUnreadActive } = useJumpToUnreadGates()
16
- const { currentPageReplyRootId, atEndOfMessageHistory } = useConversationContext()
17
13
  const firedOnce = useRef<boolean>(false)
18
14
  const { markRead } = useConversationsMarkRead({ conversation })
19
15
  const debouncedMarkRead = useMemo(
@@ -29,20 +25,10 @@ export function useMarkLatestMessageRead({ conversation }: Props) {
29
25
 
30
26
  useEffect(() => {
31
27
  if (!isActive || !shouldMarkRead) return
32
- if (currentPageReplyRootId) return
33
- if (jumpToUnreadActive && !atEndOfMessageHistory) return
34
28
 
35
29
  firedOnce.current = true
36
30
 
37
31
  debouncedMarkRead(true)
38
32
  // keeping unreadReactionCount in the dependency array to watch for changes
39
- }, [
40
- debouncedMarkRead,
41
- isActive,
42
- shouldMarkRead,
43
- unreadReactionCount,
44
- currentPageReplyRootId,
45
- jumpToUnreadActive,
46
- atEndOfMessageHistory,
47
- ])
33
+ }, [debouncedMarkRead, isActive, shouldMarkRead, unreadReactionCount])
48
34
  }
@@ -90,7 +90,7 @@ export const useSuspensePaginator = <T extends ResourceObject>(
90
90
  return { ...query, data, totalCount }
91
91
  }
92
92
 
93
- export const throwResponseError = (error: unknown) => {
93
+ const throwResponseError = (error: unknown) => {
94
94
  if (error instanceof Response) {
95
95
  throw new ResponseError(error as FailedResponse)
96
96
  }
@@ -24,6 +24,7 @@ export const HeaderRow = ({ data, nativeID, style, setTeamFilters }: HeaderRowPr
24
24
  const route = useRoute<RouteProp<ConversationFilterRecipientsScreenProps['route']>>()
25
25
 
26
26
  const { serviceTypeName, teamIdsForServiceType } = data
27
+ const displayName = serviceTypeName ?? 'No service type'
27
28
  const { team_ids: currentTeamIds = [] } = route.params
28
29
 
29
30
  const newTeamIdsAdded = [...new Set([...currentTeamIds, ...teamIdsForServiceType])]
@@ -34,7 +35,7 @@ export const HeaderRow = ({ data, nativeID, style, setTeamFilters }: HeaderRowPr
34
35
  const selectLabel = allTeamsSelected ? 'Deselect' : 'Select'
35
36
 
36
37
  const headingAccessibilityHint = `${pluralize(teamIdsForServiceType.length, 'team')} available to select`
37
- const selectAllAccessibilityLabel = `${selectLabel} ${pluralize(teamIdsForServiceType.length, 'team')} for ${serviceTypeName}`
38
+ const selectAllAccessibilityLabel = `${selectLabel} ${pluralize(teamIdsForServiceType.length, 'team')} for ${displayName}`
38
39
 
39
40
  const handleSelectAll = () => {
40
41
  setTeamFilters({
@@ -53,7 +54,7 @@ export const HeaderRow = ({ data, nativeID, style, setTeamFilters }: HeaderRowPr
53
54
  nativeID={nativeID}
54
55
  accessibilityHint={headingAccessibilityHint}
55
56
  >
56
- {serviceTypeName}
57
+ {displayName}
57
58
  </Heading>
58
59
  </View>
59
60
 
@@ -0,0 +1,108 @@
1
+ import type { TeamResponseItem } from '../../../../types'
2
+ import { decorateTeamResponseItems } from '../use_service_types_with_teams'
3
+
4
+ const makeTeam = (
5
+ overrides: Partial<TeamResponseItem> & { teamId: number; teamName: string }
6
+ ): TeamResponseItem => ({
7
+ name: overrides.teamName,
8
+ value: {
9
+ teamId: overrides.teamId,
10
+ serviceTypeId: overrides.value?.serviceTypeId ?? 0,
11
+ serviceTypeIds: overrides.value?.serviceTypeIds ?? [],
12
+ },
13
+ serviceTypeName: overrides.serviceTypeName ?? '',
14
+ serviceTypeNames: overrides.serviceTypeNames ?? [],
15
+ serviceTypeAcronyms: overrides.serviceTypeAcronyms ?? [],
16
+ teamName: overrides.teamName,
17
+ order: overrides.order ?? [0, '', overrides.teamName],
18
+ })
19
+
20
+ describe('decorateTeamResponseItems', () => {
21
+ it('groups teams under their service types', () => {
22
+ const items = [
23
+ makeTeam({
24
+ teamId: 1,
25
+ teamName: 'Worship',
26
+ value: { teamId: 1, serviceTypeId: 10, serviceTypeIds: [10] },
27
+ serviceTypeNames: ['Sunday Morning'],
28
+ }),
29
+ ]
30
+
31
+ const result = decorateTeamResponseItems(items)
32
+
33
+ expect(result).toHaveLength(1)
34
+ expect(result[0]).toMatchObject({ id: 10, name: 'Sunday Morning', teams: [{ id: 1 }] })
35
+ })
36
+
37
+ it('gives each team without a service type its own bucket', () => {
38
+ const items = [
39
+ makeTeam({
40
+ teamId: 58,
41
+ teamName: 'Services Team 58',
42
+ value: { teamId: 58, serviceTypeId: 0, serviceTypeIds: [] },
43
+ serviceTypeNames: [],
44
+ }),
45
+ makeTeam({
46
+ teamId: 99,
47
+ teamName: 'Another Typeless Team',
48
+ value: { teamId: 99, serviceTypeId: 0, serviceTypeIds: [] },
49
+ serviceTypeNames: [],
50
+ }),
51
+ ]
52
+
53
+ const result = decorateTeamResponseItems(items)
54
+
55
+ expect(result).toHaveLength(2)
56
+ expect(result[0]).toMatchObject({ id: -58, name: 'Services Team 58', teams: [{ id: 58 }] })
57
+ expect(result[1]).toMatchObject({
58
+ id: -99,
59
+ name: 'Another Typeless Team',
60
+ teams: [{ id: 99 }],
61
+ })
62
+ })
63
+
64
+ it('places teams with and without service types in the right buckets', () => {
65
+ const items = [
66
+ makeTeam({
67
+ teamId: 1,
68
+ teamName: 'Worship',
69
+ value: { teamId: 1, serviceTypeId: 10, serviceTypeIds: [10] },
70
+ serviceTypeNames: ['Sunday Morning'],
71
+ }),
72
+ makeTeam({
73
+ teamId: 58,
74
+ teamName: 'Services Team 58',
75
+ value: { teamId: 58, serviceTypeId: 0, serviceTypeIds: [] },
76
+ serviceTypeNames: [],
77
+ }),
78
+ ]
79
+
80
+ const result = decorateTeamResponseItems(items)
81
+
82
+ expect(result).toHaveLength(2)
83
+ expect(result[0]).toMatchObject({ id: -58, name: 'Services Team 58', teams: [{ id: 58 }] })
84
+ expect(result[1]).toMatchObject({ id: 10, name: 'Sunday Morning' })
85
+ })
86
+
87
+ it('filters by search query, matching teams without service types by name', () => {
88
+ const items = [
89
+ makeTeam({
90
+ teamId: 58,
91
+ teamName: 'Services Team 58',
92
+ value: { teamId: 58, serviceTypeId: 0, serviceTypeIds: [] },
93
+ serviceTypeNames: [],
94
+ }),
95
+ makeTeam({
96
+ teamId: 99,
97
+ teamName: 'Unrelated Team',
98
+ value: { teamId: 99, serviceTypeId: 0, serviceTypeIds: [] },
99
+ serviceTypeNames: [],
100
+ }),
101
+ ]
102
+
103
+ const result = decorateTeamResponseItems(items, 'Services Team')
104
+
105
+ expect(result).toHaveLength(1)
106
+ expect(result[0]).toMatchObject({ id: -58, name: 'Services Team 58', teams: [{ id: 58 }] })
107
+ })
108
+ })
@@ -13,9 +13,10 @@ export function useFlattenedArrayOfServiceTypesWithTeams({
13
13
  firstRowStyle,
14
14
  lastRowStyle,
15
15
  }: Props) {
16
- const flattenedData: SectionListData = useMemo(
17
- () =>
18
- data.flatMap(serviceType => {
16
+ const flattenedData: SectionListData = useMemo(() => {
17
+ const serviceTypeRows = data
18
+ .filter(serviceType => serviceType.id > 0)
19
+ .flatMap(serviceType => {
19
20
  const teamIdsForServiceType = serviceType.teams.map(team => team.id)
20
21
 
21
22
  return [
@@ -28,23 +29,49 @@ export function useFlattenedArrayOfServiceTypesWithTeams({
28
29
  },
29
30
  sectionStyle: firstRowStyle,
30
31
  },
31
- ...serviceType.teams.map((team, teamIdx) => {
32
- const isLastTeamInServiceType = teamIdx === serviceType.teams.length - 1
33
-
34
- return {
35
- type: SectionTypes.team as const,
36
- data: {
37
- teamName: team.name,
38
- teamId: team.id,
39
- serviceTypeId: serviceType.id,
40
- },
41
- sectionStyle: isLastTeamInServiceType ? lastRowStyle : undefined,
42
- }
43
- }),
32
+ ...serviceType.teams.map((team, teamIdx) => ({
33
+ type: SectionTypes.team as const,
34
+ data: {
35
+ teamName: team.name,
36
+ teamId: team.id,
37
+ serviceTypeId: serviceType.id,
38
+ },
39
+ sectionStyle: teamIdx === serviceType.teams.length - 1 ? lastRowStyle : undefined,
40
+ })),
44
41
  ]
45
- }),
46
- [data, firstRowStyle, lastRowStyle]
47
- )
42
+ })
43
+
44
+ // Teams without a service type (id < 0) are merged under a single "No service type" section.
45
+ // Service type ID 0 is a safe sentinel — real IDs are always positive.
46
+ const serviceTypelessTeams = data
47
+ .filter(serviceType => serviceType.id < 0)
48
+ .flatMap(serviceType => serviceType.teams)
49
+
50
+ if (serviceTypelessTeams.length === 0) return serviceTypeRows
51
+
52
+ const serviceTypelessRows: SectionListData = [
53
+ {
54
+ type: SectionTypes.header as const,
55
+ data: {
56
+ serviceTypeName: null,
57
+ serviceTypeId: 0,
58
+ teamIdsForServiceType: serviceTypelessTeams.map(t => t.id),
59
+ },
60
+ sectionStyle: firstRowStyle,
61
+ },
62
+ ...serviceTypelessTeams.map((team, teamIdx) => ({
63
+ type: SectionTypes.team as const,
64
+ data: {
65
+ teamName: team.name,
66
+ teamId: team.id,
67
+ serviceTypeId: 0,
68
+ },
69
+ sectionStyle: teamIdx === serviceTypelessTeams.length - 1 ? lastRowStyle : undefined,
70
+ })),
71
+ ]
72
+
73
+ return [...serviceTypelessRows, ...serviceTypeRows]
74
+ }, [data, firstRowStyle, lastRowStyle])
48
75
 
49
76
  return flattenedData
50
77
  }
@@ -59,45 +59,47 @@ const useTeams = ({ filterType }: { filterType: TeamFilterTypes }) => {
59
59
  return { data: result || [], ...rest }
60
60
  }
61
61
 
62
- function decorateTeamResponseItems(teamResponseItems: TeamResponseItem[], searchQuery?: string) {
63
- return teamResponseItems
64
- .filter(item => {
65
- if (!searchQuery) return true
62
+ export function decorateTeamResponseItems(
63
+ teamResponseItems: TeamResponseItem[],
64
+ searchQuery?: string
65
+ ) {
66
+ const filtered = teamResponseItems.filter(item => {
67
+ if (!searchQuery) return true
68
+ const evalMatch = (str: string) => str.toLowerCase().includes(searchQuery.toLowerCase())
69
+ return evalMatch(item.name) || evalMatch(item.serviceTypeNames?.join(',') || '')
70
+ })
66
71
 
67
- const evalMatch = (str: string) => str.toLowerCase().includes(searchQuery.toLowerCase())
68
- const teamNameMatch = evalMatch(item.name)
69
- const serviceTypeNamesMatch = evalMatch(item.serviceTypeNames?.join(',') || '')
72
+ const withServiceTypes = filtered.filter(item => item.value.serviceTypeIds.length > 0)
73
+ const withoutServiceTypes = filtered.filter(item => item.value.serviceTypeIds.length === 0)
70
74
 
71
- return teamNameMatch || serviceTypeNamesMatch
72
- })
73
- .map(({ value, serviceTypeNames, teamName }) => {
74
- return {
75
- service_types: value.serviceTypeIds.map((serviceTypeId, i) => ({
76
- id: serviceTypeId,
77
- name: serviceTypeNames[i],
78
- })),
79
- team: {
80
- id: value.teamId,
81
- name: teamName,
82
- },
83
- }
75
+ // Negative team ID is used as a unique sentinel — real service type IDs are always positive.
76
+ const typelessEntries: ServiceTypeWithTeams[] = withoutServiceTypes.map(
77
+ ({ value, teamName }) => ({
78
+ id: -value.teamId,
79
+ name: teamName,
80
+ teams: [{ id: value.teamId, name: teamName }],
84
81
  })
82
+ )
83
+
84
+ const typedEntries = withServiceTypes
85
+ .map(({ value, serviceTypeNames, teamName }) => ({
86
+ service_types: value.serviceTypeIds.map((serviceTypeId, i) => ({
87
+ id: serviceTypeId,
88
+ name: serviceTypeNames[i],
89
+ })),
90
+ team: { id: value.teamId, name: teamName },
91
+ }))
85
92
  .reduce((acc: ServiceTypeWithTeams[], { service_types, team }) => {
86
93
  service_types.forEach(serviceType => {
87
94
  let serviceTypeEntry = acc.find(entry => entry.id === serviceType.id)
88
-
89
95
  if (!serviceTypeEntry) {
90
- serviceTypeEntry = {
91
- id: serviceType.id,
92
- name: serviceType.name,
93
- teams: [],
94
- }
96
+ serviceTypeEntry = { id: serviceType.id, name: serviceType.name, teams: [] }
95
97
  acc.push(serviceTypeEntry)
96
98
  }
97
-
98
- const initialTeams = serviceTypeEntry.teams
99
- serviceTypeEntry.teams = uniqBy([...initialTeams, team], 'id')
99
+ serviceTypeEntry.teams = uniqBy([...serviceTypeEntry.teams, team], 'id')
100
100
  })
101
101
  return acc
102
102
  }, [])
103
+
104
+ return [...typelessEntries, ...typedEntries]
103
105
  }
@@ -17,7 +17,7 @@ export enum SectionTypes {
17
17
  }
18
18
 
19
19
  export interface ServiceTypeProps {
20
- serviceTypeName: string
20
+ serviceTypeName: string | null
21
21
  serviceTypeId: number
22
22
  teamIdsForServiceType: number[]
23
23
  }