@adatechnology/conversations-ui 0.5.0 → 0.6.1
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/README.md +82 -20
- package/dist/{chunk-JXJCPN34.js → chunk-FAQLC3TX.js} +1 -1
- package/dist/{chunk-RJMSM3YA.js → chunk-O6457XHK.js} +17 -11
- package/dist/chunk-OVFCLRQQ.js +84 -0
- package/dist/index.d.ts +14 -4
- package/dist/index.js +7 -7
- package/dist/participant/index.d.ts +25 -1
- package/dist/participant/index.js +454 -194
- package/dist/preview/index.js +2 -2
- package/dist/styles.css +106 -0
- package/package.json +3 -3
- package/src/MessageBubble.test.tsx +2 -2
- package/src/MessageText.test.tsx +27 -10
- package/src/MessageText.tsx +25 -17
- package/src/MessageTextCopiedBadge.tsx +10 -2
- package/src/StatusTicks.test.tsx +27 -6
- package/src/StatusTicks.tsx +30 -15
- package/src/cssBlocks.test-helper.ts +31 -0
- package/src/legacyChannels.golden.test.ts +89 -0
- package/src/legacyDefaults.golden.test.tsx +127 -0
- package/src/legacyStyles.fixture.css +1121 -0
- package/src/legacyStyles.golden.test.ts +92 -0
- package/src/messageTextCopyInteraction.test.ts +75 -0
- package/src/messageTextCopyInteraction.ts +37 -0
- package/src/participant/ParticipantChannelBadges.test.tsx +59 -0
- package/src/participant/ParticipantChannelBadges.tsx +75 -0
- package/src/participant/ParticipantConversations.tsx +13 -6
- package/src/participant/ParticipantConversationsScreen.tsx +18 -7
- package/src/participant/ParticipantInbox.test.tsx +99 -2
- package/src/participant/ParticipantInbox.tsx +27 -46
- package/src/participant/ParticipantInboxRow.tsx +75 -0
- package/src/participant/ParticipantInboxSearchField.tsx +34 -0
- package/src/participant/ParticipantMessageBubble.test.tsx +2 -0
- package/src/participant/ParticipantMessageBubble.tsx +3 -3
- package/src/participant/ParticipantProtocolBadge.tsx +74 -0
- package/src/participant/ParticipantProtocolView.test.tsx +208 -0
- package/src/participant/ParticipantThread.test.tsx +19 -2
- package/src/participant/ParticipantThread.tsx +25 -15
- package/src/participant/index.ts +1 -0
- package/src/participant/participant.types.ts +9 -0
- package/src/participant/participantChannels.test.ts +42 -0
- package/src/participant/participantChannels.ts +33 -0
- package/src/participant/participantGrouping.test.ts +101 -3
- package/src/participant/participantGrouping.ts +20 -7
- package/src/participant/participantLabels.ts +29 -0
- package/src/participant/participantProtocol.test.ts +104 -0
- package/src/participant/participantProtocol.ts +52 -0
- package/src/participant/participantProtocolCopy.test.ts +45 -0
- package/src/participant/participantProtocolCopy.ts +32 -0
- package/src/participantLayoutShrink.test.ts +82 -0
- package/src/styles.css +22 -1
- package/dist/chunk-WSFGNR5R.js +0 -55
package/src/participant/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, type ParticipantConversations
|
|
|
4
4
|
export type {
|
|
5
5
|
ParticipantConversationsClassNames,
|
|
6
6
|
ParticipantSubjectGroup,
|
|
7
|
+
ParticipantSubjectIconRenderer,
|
|
7
8
|
ParticipantInboxView,
|
|
8
9
|
ParticipantInboxSection,
|
|
9
10
|
GroupParticipantConversationsParams,
|
|
@@ -8,6 +8,9 @@ export type ParticipantSubjectGroup = {
|
|
|
8
8
|
readonly icon?: ReactNode
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/** Host-drawn icon for a list row; null/undefined falls back to the subject group icon. */
|
|
12
|
+
export type ParticipantSubjectIconRenderer = (conversation: ParticipantConversationSummary) => ReactNode
|
|
13
|
+
|
|
11
14
|
export type ParticipantInboxSection = {
|
|
12
15
|
/** 'awaiting' | 'closed' | 'other' or the subjectType of a group. */
|
|
13
16
|
readonly key: string
|
|
@@ -27,6 +30,10 @@ export type ParticipantInboxView = {
|
|
|
27
30
|
readonly sections: readonly ParticipantInboxSection[]
|
|
28
31
|
readonly filters: readonly ParticipantInboxFilter[]
|
|
29
32
|
readonly showFilters: boolean
|
|
33
|
+
/** The filter actually applied: 'all' when the requested one has no conversations left. */
|
|
34
|
+
readonly activeFilter: string
|
|
35
|
+
/** The search finds conversations, but none under the active subject filter. */
|
|
36
|
+
readonly hasMatchesOutsideFilter: boolean
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
export type GroupParticipantConversationsParams = {
|
|
@@ -34,6 +41,8 @@ export type GroupParticipantConversationsParams = {
|
|
|
34
41
|
readonly subjectGroups: readonly ParticipantSubjectGroup[]
|
|
35
42
|
/** subjectType, or 'all'/undefined for everything. */
|
|
36
43
|
readonly filter?: string
|
|
44
|
+
/** Narrows the rows only: chips and their counts always describe the whole list. */
|
|
45
|
+
readonly searchQuery?: string
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
export type ParticipantConversationsClassNames = {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
4
|
+
import {
|
|
5
|
+
PARTICIPANT_CHANNEL_ORDER,
|
|
6
|
+
resolveParticipantChannelLabel,
|
|
7
|
+
resolveParticipantChannels,
|
|
8
|
+
} from './participantChannels'
|
|
9
|
+
|
|
10
|
+
const channelsOf = (input: readonly string[] | undefined) =>
|
|
11
|
+
resolveParticipantChannels(input).map((descriptor) => descriptor.channel)
|
|
12
|
+
|
|
13
|
+
describe('resolveParticipantChannels', () => {
|
|
14
|
+
it('returns nothing for absent or empty input', () => {
|
|
15
|
+
expect(channelsOf(undefined)).toEqual([])
|
|
16
|
+
expect(channelsOf([])).toEqual([])
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('orders deterministically whatever the server order', () => {
|
|
20
|
+
expect(channelsOf(['webchat', 'email', 'whatsapp', 'app', 'portal'])).toEqual([
|
|
21
|
+
'app',
|
|
22
|
+
'whatsapp',
|
|
23
|
+
'email',
|
|
24
|
+
'portal',
|
|
25
|
+
'webchat',
|
|
26
|
+
])
|
|
27
|
+
expect(channelsOf(['whatsapp', 'app'])).toEqual(['app', 'whatsapp'])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('dedupes and ignores unknown channels', () => {
|
|
31
|
+
expect(channelsOf(['whatsapp', 'whatsapp', 'sms', 'app'])).toEqual(['app', 'whatsapp'])
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('maps every channel to an icon and a label', () => {
|
|
35
|
+
expect(PARTICIPANT_CHANNEL_ORDER).toHaveLength(5)
|
|
36
|
+
const labels = PARTICIPANT_CHANNEL_ORDER.map((descriptor) =>
|
|
37
|
+
resolveParticipantChannelLabel(descriptor, DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS),
|
|
38
|
+
)
|
|
39
|
+
expect(labels).toEqual(['App', 'WhatsApp', 'Email', 'Portal', 'Web chat'])
|
|
40
|
+
expect(new Set(PARTICIPANT_CHANNEL_ORDER.map((descriptor) => descriptor.iconName)).size).toBe(5)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ConversationChannel } from '@adatechnology/conversation-contracts'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
4
|
+
|
|
5
|
+
export type ParticipantChannelDescriptor = {
|
|
6
|
+
readonly channel: ConversationChannel
|
|
7
|
+
readonly iconName: 'Smartphone' | 'MessageCircle' | 'Mail' | 'Globe' | 'MessagesSquare'
|
|
8
|
+
readonly labelKey: 'channelApp' | 'channelWhatsapp' | 'channelEmail' | 'channelPortal' | 'channelWebchat'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const PARTICIPANT_CHANNEL_ORDER: readonly ParticipantChannelDescriptor[] = [
|
|
12
|
+
{ channel: 'app', iconName: 'Smartphone', labelKey: 'channelApp' },
|
|
13
|
+
{ channel: 'whatsapp', iconName: 'MessageCircle', labelKey: 'channelWhatsapp' },
|
|
14
|
+
{ channel: 'email', iconName: 'Mail', labelKey: 'channelEmail' },
|
|
15
|
+
{ channel: 'portal', iconName: 'Globe', labelKey: 'channelPortal' },
|
|
16
|
+
{ channel: 'webchat', iconName: 'MessagesSquare', labelKey: 'channelWebchat' },
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
/** Stable badge order regardless of the server order; duplicates and unknown values (runtime) are dropped. */
|
|
20
|
+
export function resolveParticipantChannels(
|
|
21
|
+
channels: readonly string[] | undefined,
|
|
22
|
+
): readonly ParticipantChannelDescriptor[] {
|
|
23
|
+
if (channels === undefined || channels.length === 0) return []
|
|
24
|
+
const present = new Set<string>(channels)
|
|
25
|
+
return PARTICIPANT_CHANNEL_ORDER.filter((descriptor) => present.has(descriptor.channel))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveParticipantChannelLabel(
|
|
29
|
+
descriptor: ParticipantChannelDescriptor,
|
|
30
|
+
labels: ParticipantConversationsLabels,
|
|
31
|
+
): string {
|
|
32
|
+
return labels[descriptor.labelKey]
|
|
33
|
+
}
|
|
@@ -4,7 +4,9 @@ import type { ParticipantConversationSummary } from '@adatechnology/conversation
|
|
|
4
4
|
|
|
5
5
|
import { groupParticipantConversations } from './participantGrouping'
|
|
6
6
|
|
|
7
|
-
function build(
|
|
7
|
+
function build(
|
|
8
|
+
overrides: Partial<ParticipantConversationSummary> & { subjectId: string },
|
|
9
|
+
): ParticipantConversationSummary {
|
|
8
10
|
return {
|
|
9
11
|
subjectType: 'invoice',
|
|
10
12
|
subjectLabel: 'Label',
|
|
@@ -60,7 +62,12 @@ describe('groupParticipantConversations', () => {
|
|
|
60
62
|
build({ subjectId: 'a', subjectType: 'zeta', lastMessageAt: null, subjectLabel: 'A' }),
|
|
61
63
|
build({ subjectId: 'b', subjectType: 'zeta', lastMessageAt: '2026-10-01T10:00:00.000Z', subjectLabel: 'Beta' }),
|
|
62
64
|
build({ subjectId: 'c', subjectType: 'zeta', lastMessageAt: '2026-10-02T10:00:00.000Z', subjectLabel: 'Z' }),
|
|
63
|
-
build({
|
|
65
|
+
build({
|
|
66
|
+
subjectId: 'd',
|
|
67
|
+
subjectType: 'zeta',
|
|
68
|
+
lastMessageAt: '2026-10-01T10:00:00.000Z',
|
|
69
|
+
subjectLabel: 'Alpha',
|
|
70
|
+
}),
|
|
64
71
|
],
|
|
65
72
|
})
|
|
66
73
|
expect(view.sections[0]?.conversations.map((c) => c.subjectId)).toEqual(['c', 'd', 'b', 'a'])
|
|
@@ -133,6 +140,97 @@ describe('groupParticipantConversations', () => {
|
|
|
133
140
|
],
|
|
134
141
|
})
|
|
135
142
|
expect(view.sections.map((section) => section.conversations.map((c) => c.subjectId))).toEqual([['2'], ['3']])
|
|
136
|
-
expect(
|
|
143
|
+
expect(
|
|
144
|
+
groupParticipantConversations({
|
|
145
|
+
subjectGroups: GROUPS,
|
|
146
|
+
filter: 'all',
|
|
147
|
+
conversations: [build({ subjectId: '1' })],
|
|
148
|
+
}).sections,
|
|
149
|
+
).toHaveLength(1)
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe('groupParticipantConversations with a search query', () => {
|
|
154
|
+
const conversations = [
|
|
155
|
+
build({ subjectId: '1', subjectType: 'alpha', protocol: '261009-K7M2', unreadCount: 2 }),
|
|
156
|
+
build({ subjectId: '2', subjectType: 'zeta', protocol: '261008-AB23', unreadCount: 1 }),
|
|
157
|
+
build({ subjectId: '3', subjectType: 'zeta', protocol: '261007-CD45' }),
|
|
158
|
+
]
|
|
159
|
+
const idsOf = (view: ReturnType<typeof groupParticipantConversations>) =>
|
|
160
|
+
view.sections.flatMap((section) => section.conversations.map((item) => item.subjectId))
|
|
161
|
+
|
|
162
|
+
it('keeps chips and counts computed on the whole list, not on the search result', () => {
|
|
163
|
+
const view = groupParticipantConversations({
|
|
164
|
+
conversations,
|
|
165
|
+
subjectGroups: GROUPS,
|
|
166
|
+
filter: 'all',
|
|
167
|
+
searchQuery: 'k7m2',
|
|
168
|
+
})
|
|
169
|
+
expect(idsOf(view)).toEqual(['1'])
|
|
170
|
+
expect(view.showFilters).toBe(true)
|
|
171
|
+
expect(view.filters.map((filter) => [filter.subjectType, filter.unreadCount])).toEqual([
|
|
172
|
+
['all', 3],
|
|
173
|
+
['zeta', 1],
|
|
174
|
+
['alpha', 2],
|
|
175
|
+
])
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('chip of another subject plus the protocol of a conversation outside it: chips stay and the view says so', () => {
|
|
179
|
+
const view = groupParticipantConversations({
|
|
180
|
+
conversations,
|
|
181
|
+
subjectGroups: GROUPS,
|
|
182
|
+
filter: 'zeta',
|
|
183
|
+
searchQuery: '261009',
|
|
184
|
+
})
|
|
185
|
+
expect(view.sections).toEqual([])
|
|
186
|
+
expect(view.showFilters).toBe(true)
|
|
187
|
+
expect(view.hasMatchesOutsideFilter).toBe(true)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('a query that matches inside the active filter shows only those rows', () => {
|
|
191
|
+
const view = groupParticipantConversations({
|
|
192
|
+
conversations,
|
|
193
|
+
subjectGroups: GROUPS,
|
|
194
|
+
filter: 'zeta',
|
|
195
|
+
searchQuery: 'ab23',
|
|
196
|
+
})
|
|
197
|
+
expect(idsOf(view)).toEqual(['2'])
|
|
198
|
+
expect(view.hasMatchesOutsideFilter).toBe(false)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('a query that matches nothing anywhere is not an outside-filter match', () => {
|
|
202
|
+
const view = groupParticipantConversations({
|
|
203
|
+
conversations,
|
|
204
|
+
subjectGroups: GROUPS,
|
|
205
|
+
filter: 'zeta',
|
|
206
|
+
searchQuery: 'zzz',
|
|
207
|
+
})
|
|
208
|
+
expect(view.hasMatchesOutsideFilter).toBe(false)
|
|
209
|
+
expect(view.showFilters).toBe(true)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('without a query nothing is outside the filter', () => {
|
|
213
|
+
const view = groupParticipantConversations({ conversations, subjectGroups: GROUPS, filter: 'zeta' })
|
|
214
|
+
expect(view.hasMatchesOutsideFilter).toBe(false)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('a filter pointing at a subject with no conversations behaves as all', () => {
|
|
218
|
+
const view = groupParticipantConversations({
|
|
219
|
+
conversations: [build({ subjectId: '1', subjectType: 'zeta' })],
|
|
220
|
+
subjectGroups: GROUPS,
|
|
221
|
+
filter: 'alpha',
|
|
222
|
+
})
|
|
223
|
+
expect(idsOf(view)).toEqual(['1'])
|
|
224
|
+
expect(view.activeFilter).toBe('all')
|
|
225
|
+
expect(view.hasMatchesOutsideFilter).toBe(false)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('keeps a filter that still has conversations as active', () => {
|
|
229
|
+
const view = groupParticipantConversations({
|
|
230
|
+
conversations: [build({ subjectId: '1', subjectType: 'zeta' })],
|
|
231
|
+
subjectGroups: GROUPS,
|
|
232
|
+
filter: 'zeta',
|
|
233
|
+
})
|
|
234
|
+
expect(view.activeFilter).toBe('zeta')
|
|
137
235
|
})
|
|
138
236
|
})
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
ParticipantInboxSection,
|
|
7
7
|
ParticipantInboxView,
|
|
8
8
|
} from './participant.types'
|
|
9
|
+
import { filterConversationsBySearch } from './participantProtocol'
|
|
9
10
|
|
|
10
11
|
const ALL_FILTER = 'all'
|
|
11
12
|
const SECTION_AWAITING = 'awaiting'
|
|
@@ -53,12 +54,18 @@ function buildFilters(
|
|
|
53
54
|
return { filters: [all, ...groupFilters], showFilters: groupFilters.length >= 2 }
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
function resolveActiveFilter(params: GroupParticipantConversationsParams): string {
|
|
58
|
+
const { filter, conversations } = params
|
|
59
|
+
if (filter === undefined || filter === ALL_FILTER) return ALL_FILTER
|
|
60
|
+
return conversations.some((conversation) => conversation.subjectType === filter) ? filter : ALL_FILTER
|
|
61
|
+
}
|
|
62
|
+
|
|
56
63
|
export function groupParticipantConversations(params: GroupParticipantConversationsParams): ParticipantInboxView {
|
|
57
|
-
const {
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
const { subjectGroups } = params
|
|
65
|
+
const filter = resolveActiveFilter(params)
|
|
66
|
+
const isFiltered = filter !== ALL_FILTER
|
|
67
|
+
const searched = filterConversationsBySearch(params.conversations, params.searchQuery ?? '')
|
|
68
|
+
const visible = isFiltered ? searched.filter((conversation) => conversation.subjectType === filter) : searched
|
|
62
69
|
const knownTypes = new Set(subjectGroups.map((group) => group.subjectType))
|
|
63
70
|
|
|
64
71
|
const buckets = new Map<string, ParticipantConversationSummary[]>()
|
|
@@ -67,11 +74,17 @@ export function groupParticipantConversations(params: GroupParticipantConversati
|
|
|
67
74
|
buckets.set(key, [...(buckets.get(key) ?? []), conversation])
|
|
68
75
|
}
|
|
69
76
|
|
|
70
|
-
const orderedKeys = [
|
|
77
|
+
const orderedKeys = [
|
|
78
|
+
SECTION_AWAITING,
|
|
79
|
+
...subjectGroups.map((group) => group.subjectType),
|
|
80
|
+
SECTION_OTHER,
|
|
81
|
+
SECTION_CLOSED,
|
|
82
|
+
]
|
|
71
83
|
const sections = orderedKeys.flatMap((key) => {
|
|
72
84
|
const bucket = buckets.get(key)
|
|
73
85
|
return bucket === undefined ? [] : [buildSection(key, bucket)]
|
|
74
86
|
})
|
|
75
87
|
|
|
76
|
-
|
|
88
|
+
const hasMatchesOutsideFilter = isFiltered && visible.length === 0 && searched.length > 0
|
|
89
|
+
return { sections, hasMatchesOutsideFilter, activeFilter: filter, ...buildFilters(params) }
|
|
77
90
|
}
|
|
@@ -38,6 +38,21 @@ export type ParticipantConversationsLabels = {
|
|
|
38
38
|
readonly loading: string
|
|
39
39
|
readonly loadError: string
|
|
40
40
|
readonly loadMore: string
|
|
41
|
+
readonly searchLabel: string
|
|
42
|
+
readonly searchPlaceholder: string
|
|
43
|
+
readonly noResults: string
|
|
44
|
+
readonly noResultsInFilter: string
|
|
45
|
+
readonly noResultsLoadedOnly: string
|
|
46
|
+
/** Spoken before the protocol code, e.g. "Protocol 261009-K7M2". */
|
|
47
|
+
readonly protocolPrefix: string
|
|
48
|
+
readonly copyProtocol: string
|
|
49
|
+
readonly protocolCopied: string
|
|
50
|
+
readonly channelsGroup: string
|
|
51
|
+
readonly channelApp: string
|
|
52
|
+
readonly channelWhatsapp: string
|
|
53
|
+
readonly channelEmail: string
|
|
54
|
+
readonly channelPortal: string
|
|
55
|
+
readonly channelWebchat: string
|
|
41
56
|
}
|
|
42
57
|
|
|
43
58
|
export const DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS: ParticipantConversationsLabels = {
|
|
@@ -79,6 +94,20 @@ export const DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS: ParticipantConversationsL
|
|
|
79
94
|
loading: 'Loading…',
|
|
80
95
|
loadError: 'Could not load. Check your connection and try again.',
|
|
81
96
|
loadMore: 'Load more',
|
|
97
|
+
searchLabel: 'Search conversations',
|
|
98
|
+
searchPlaceholder: 'Search by title or protocol',
|
|
99
|
+
noResults: 'No conversations found',
|
|
100
|
+
noResultsInFilter: 'No results in this filter — try All',
|
|
101
|
+
noResultsLoadedOnly: 'Nothing in the loaded conversations — load more',
|
|
102
|
+
protocolPrefix: 'Protocol',
|
|
103
|
+
copyProtocol: 'Copy protocol',
|
|
104
|
+
protocolCopied: 'Protocol copied',
|
|
105
|
+
channelsGroup: 'Channels in this conversation',
|
|
106
|
+
channelApp: 'App',
|
|
107
|
+
channelWhatsapp: 'WhatsApp',
|
|
108
|
+
channelEmail: 'Email',
|
|
109
|
+
channelPortal: 'Portal',
|
|
110
|
+
channelWebchat: 'Web chat',
|
|
82
111
|
}
|
|
83
112
|
|
|
84
113
|
export function formatParticipantLabel(template: string, count: number): string {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { buildConversation } from './participantFixtures.test-helper'
|
|
4
|
+
import {
|
|
5
|
+
PROTOCOL_SEARCH_CONVERSATION_THRESHOLD,
|
|
6
|
+
filterConversationsBySearch,
|
|
7
|
+
isInboxSearchVisible,
|
|
8
|
+
matchesProtocolQuery,
|
|
9
|
+
normalizeProtocolQuery,
|
|
10
|
+
shouldShowInboxSearch,
|
|
11
|
+
} from './participantProtocol'
|
|
12
|
+
|
|
13
|
+
describe('normalizeProtocolQuery', () => {
|
|
14
|
+
it('uppercases and drops dashes and spaces', () => {
|
|
15
|
+
expect(normalizeProtocolQuery(' 261009-k7 m2 ')).toBe('261009K7M2')
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe('matchesProtocolQuery', () => {
|
|
20
|
+
it('matches partially, without dash and without case', () => {
|
|
21
|
+
expect(matchesProtocolQuery('261009-K7M2', '261009k7')).toBe(true)
|
|
22
|
+
expect(matchesProtocolQuery('261009-K7M2', 'k7m2')).toBe(true)
|
|
23
|
+
expect(matchesProtocolQuery('261009-K7M2', '9-k')).toBe(true)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('does not match a different code', () => {
|
|
27
|
+
expect(matchesProtocolQuery('261009-K7M2', 'ZZZ')).toBe(false)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('an empty query matches everything, even a missing protocol', () => {
|
|
31
|
+
expect(matchesProtocolQuery(undefined, '')).toBe(true)
|
|
32
|
+
expect(matchesProtocolQuery('261009-K7M2', ' ')).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('a missing protocol never matches a real query', () => {
|
|
36
|
+
expect(matchesProtocolQuery(undefined, 'K7')).toBe(false)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('filterConversationsBySearch', () => {
|
|
41
|
+
const conversations = [
|
|
42
|
+
buildConversation({ subjectId: '1', subjectLabel: 'Damaged item', protocol: '261009-K7M2' }),
|
|
43
|
+
buildConversation({ subjectId: '2', subjectLabel: 'Late item' }),
|
|
44
|
+
buildConversation({ subjectId: '3', subjectLabel: 'Other topic', protocol: '261008-AB23' }),
|
|
45
|
+
]
|
|
46
|
+
const ids = (query: string) => filterConversationsBySearch(conversations, query).map((item) => item.subjectId)
|
|
47
|
+
|
|
48
|
+
it('returns everything for an empty query', () => {
|
|
49
|
+
expect(ids('')).toEqual(['1', '2', '3'])
|
|
50
|
+
expect(ids(' ')).toEqual(['1', '2', '3'])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('matches the title without accent or case', () => {
|
|
54
|
+
expect(ids('DAMAGED')).toEqual(['1'])
|
|
55
|
+
expect(ids('látè')).toEqual(['2'])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('matches the protocol partially, without dash and case', () => {
|
|
59
|
+
expect(ids('k7m')).toEqual(['1'])
|
|
60
|
+
expect(ids('261008')).toEqual(['3'])
|
|
61
|
+
expect(ids('2610')).toEqual(['1', '3'])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('a query of only separators does not match every protocol', () => {
|
|
65
|
+
expect(ids('-')).toEqual([])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('returns nothing when neither title nor protocol match', () => {
|
|
69
|
+
expect(ids('zzz')).toEqual([])
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('shouldShowInboxSearch', () => {
|
|
74
|
+
it('shows when some conversation has a protocol', () => {
|
|
75
|
+
expect(shouldShowInboxSearch([buildConversation({ subjectId: '1', protocol: '261009-K7M2' })])).toBe(true)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('shows beyond the threshold even without protocols', () => {
|
|
79
|
+
const many = Array.from({ length: PROTOCOL_SEARCH_CONVERSATION_THRESHOLD + 1 }, (_, index) =>
|
|
80
|
+
buildConversation({ subjectId: String(index) }),
|
|
81
|
+
)
|
|
82
|
+
expect(shouldShowInboxSearch(many)).toBe(true)
|
|
83
|
+
expect(shouldShowInboxSearch(many.slice(0, PROTOCOL_SEARCH_CONVERSATION_THRESHOLD))).toBe(false)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('hides for a short list without protocols', () => {
|
|
87
|
+
expect(shouldShowInboxSearch([buildConversation({ subjectId: '1' })])).toBe(false)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('isInboxSearchVisible', () => {
|
|
92
|
+
const short = [buildConversation({ subjectId: '1' })]
|
|
93
|
+
const withProtocol = [buildConversation({ subjectId: '1', protocol: '261009-K7M2' })]
|
|
94
|
+
|
|
95
|
+
it('follows the threshold rule when there is no query', () => {
|
|
96
|
+
expect(isInboxSearchVisible({ conversations: short, searchQuery: '' })).toBe(false)
|
|
97
|
+
expect(isInboxSearchVisible({ conversations: withProtocol, searchQuery: '' })).toBe(true)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('stays visible while a query is applied, even if a refresh shrank the list', () => {
|
|
101
|
+
expect(isInboxSearchVisible({ conversations: short, searchQuery: 'k7' })).toBe(true)
|
|
102
|
+
expect(isInboxSearchVisible({ conversations: [], searchQuery: 'k7' })).toBe(true)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ParticipantConversationSummary } from '@adatechnology/conversation-contracts'
|
|
2
|
+
|
|
3
|
+
/** Search appears when the list is long even if no conversation carries a protocol. */
|
|
4
|
+
export const PROTOCOL_SEARCH_CONVERSATION_THRESHOLD = 8
|
|
5
|
+
|
|
6
|
+
export function normalizeProtocolQuery(text: string): string {
|
|
7
|
+
return text.replace(/[-\s]/g, '').toUpperCase()
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function matchesProtocolQuery(protocol: string | undefined, query: string): boolean {
|
|
11
|
+
const normalizedQuery = normalizeProtocolQuery(query)
|
|
12
|
+
if (normalizedQuery === '') return true
|
|
13
|
+
if (protocol === undefined) return false
|
|
14
|
+
return normalizeProtocolQuery(protocol).includes(normalizedQuery)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function foldText(text: string): string {
|
|
18
|
+
return text.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function matchesTitle(subjectLabel: string, query: string): boolean {
|
|
22
|
+
return foldText(subjectLabel).includes(foldText(query))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function filterConversationsBySearch(
|
|
26
|
+
conversations: readonly ParticipantConversationSummary[],
|
|
27
|
+
query: string,
|
|
28
|
+
): readonly ParticipantConversationSummary[] {
|
|
29
|
+
const trimmedQuery = query.trim()
|
|
30
|
+
if (trimmedQuery === '') return conversations
|
|
31
|
+
const hasProtocolTerm = normalizeProtocolQuery(trimmedQuery) !== ''
|
|
32
|
+
return conversations.filter(
|
|
33
|
+
(conversation) =>
|
|
34
|
+
matchesTitle(conversation.subjectLabel, trimmedQuery) ||
|
|
35
|
+
(hasProtocolTerm && matchesProtocolQuery(conversation.protocol, trimmedQuery)),
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type InboxSearchVisibilityParams = {
|
|
40
|
+
readonly conversations: readonly ParticipantConversationSummary[]
|
|
41
|
+
readonly searchQuery: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** An applied query keeps the field, so a refresh that shrinks the list cannot strand it. */
|
|
45
|
+
export function isInboxSearchVisible({ conversations, searchQuery }: InboxSearchVisibilityParams): boolean {
|
|
46
|
+
return searchQuery !== '' || shouldShowInboxSearch(conversations)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function shouldShowInboxSearch(conversations: readonly ParticipantConversationSummary[]): boolean {
|
|
50
|
+
if (conversations.length > PROTOCOL_SEARCH_CONVERSATION_THRESHOLD) return true
|
|
51
|
+
return conversations.some((conversation) => conversation.protocol !== undefined)
|
|
52
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { INITIAL_PROTOCOL_COPY_STATE, copyProtocolToClipboard, protocolCopyReducer } from './participantProtocolCopy'
|
|
4
|
+
|
|
5
|
+
describe('copyProtocolToClipboard', () => {
|
|
6
|
+
it('writes the protocol and reports success', async () => {
|
|
7
|
+
const written: string[] = []
|
|
8
|
+
const clipboard = { writeText: async (text: string) => void written.push(text) }
|
|
9
|
+
expect(await copyProtocolToClipboard('261009-K7M2', clipboard)).toBe(true)
|
|
10
|
+
expect(written).toEqual(['261009-K7M2'])
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('reports failure silently when the clipboard rejects', async () => {
|
|
14
|
+
const clipboard = { writeText: async () => Promise.reject(new Error('denied')) }
|
|
15
|
+
expect(await copyProtocolToClipboard('261009-K7M2', clipboard)).toBe(false)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('reports failure when there is no clipboard', async () => {
|
|
19
|
+
expect(await copyProtocolToClipboard('261009-K7M2', undefined)).toBe(false)
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('protocolCopyReducer', () => {
|
|
24
|
+
it('starts not copied', () => {
|
|
25
|
+
expect(INITIAL_PROTOCOL_COPY_STATE).toEqual({ copyCount: 0, isCopied: false })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('a copy marks it copied and counts it', () => {
|
|
29
|
+
expect(protocolCopyReducer(INITIAL_PROTOCOL_COPY_STATE, { type: 'copied' })).toEqual({
|
|
30
|
+
copyCount: 1,
|
|
31
|
+
isCopied: true,
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('a second copy while still copied bumps the counter so the timer restarts and the notice is announced again', () => {
|
|
36
|
+
const first = protocolCopyReducer(INITIAL_PROTOCOL_COPY_STATE, { type: 'copied' })
|
|
37
|
+
const second = protocolCopyReducer(first, { type: 'copied' })
|
|
38
|
+
expect(second).toEqual({ copyCount: 2, isCopied: true })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('expiry clears the notice and keeps the count', () => {
|
|
42
|
+
const copied = protocolCopyReducer(INITIAL_PROTOCOL_COPY_STATE, { type: 'copied' })
|
|
43
|
+
expect(protocolCopyReducer(copied, { type: 'expired' })).toEqual({ copyCount: 1, isCopied: false })
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type ProtocolClipboard = {
|
|
2
|
+
readonly writeText: (text: string) => Promise<void>
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/** Failure is silent by design: the protocol stays visible and selectable. */
|
|
6
|
+
export async function copyProtocolToClipboard(
|
|
7
|
+
protocol: string,
|
|
8
|
+
clipboard: ProtocolClipboard | undefined,
|
|
9
|
+
): Promise<boolean> {
|
|
10
|
+
if (clipboard === undefined) return false
|
|
11
|
+
try {
|
|
12
|
+
await clipboard.writeText(protocol)
|
|
13
|
+
return true
|
|
14
|
+
} catch {
|
|
15
|
+
return false
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type ProtocolCopyState = {
|
|
20
|
+
readonly copyCount: number
|
|
21
|
+
readonly isCopied: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ProtocolCopyAction = { readonly type: 'copied' } | { readonly type: 'expired' }
|
|
25
|
+
|
|
26
|
+
export const INITIAL_PROTOCOL_COPY_STATE: ProtocolCopyState = { copyCount: 0, isCopied: false }
|
|
27
|
+
|
|
28
|
+
/** The counter changes on every copy so a repeat inside the window restarts the timer. */
|
|
29
|
+
export function protocolCopyReducer(state: ProtocolCopyState, action: ProtocolCopyAction): ProtocolCopyState {
|
|
30
|
+
if (action.type === 'copied') return { copyCount: state.copyCount + 1, isCopied: true }
|
|
31
|
+
return { ...state, isCopied: false }
|
|
32
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { describe, expect, it } from 'bun:test'
|
|
4
|
+
|
|
5
|
+
import { splitBlocks } from './cssBlocks.test-helper'
|
|
6
|
+
|
|
7
|
+
const CSS = readFileSync(join(import.meta.dir, 'styles.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
|
|
8
|
+
|
|
9
|
+
const NON_SHRINKING_ITEMS = [
|
|
10
|
+
'.cv-p-filters',
|
|
11
|
+
'.cv-p-quick',
|
|
12
|
+
'.cv-p-search',
|
|
13
|
+
'.cv-p-composer',
|
|
14
|
+
'.cv-p-thread__header',
|
|
15
|
+
'.cv-p-thread__subject-card',
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
type FlexState = { grow: number; shrink: number }
|
|
19
|
+
|
|
20
|
+
const FLEX_KEYWORDS: Record<string, FlexState> = {
|
|
21
|
+
none: { grow: 0, shrink: 0 },
|
|
22
|
+
auto: { grow: 1, shrink: 1 },
|
|
23
|
+
initial: { grow: 0, shrink: 1 },
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseFlexShorthand(value: string): FlexState {
|
|
27
|
+
const keyword = FLEX_KEYWORDS[value]
|
|
28
|
+
if (keyword) return { ...keyword }
|
|
29
|
+
const numbers = value
|
|
30
|
+
.split(/\s+/)
|
|
31
|
+
.filter((token) => /^\d*\.?\d+$/.test(token))
|
|
32
|
+
.map(Number)
|
|
33
|
+
return { grow: numbers[0] ?? 0, shrink: numbers[1] ?? 1 }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function cleanValue(raw: string): string {
|
|
37
|
+
return raw
|
|
38
|
+
.replace(/!important/g, '')
|
|
39
|
+
.trim()
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Only depth-0 style rules count (@media/@supports are conditional); later declarations win.
|
|
44
|
+
function effectiveFlex(selector: string): FlexState | undefined {
|
|
45
|
+
let state: FlexState | undefined
|
|
46
|
+
for (const { prelude, body } of splitBlocks(CSS)) {
|
|
47
|
+
if (body === undefined || prelude.startsWith('@')) continue
|
|
48
|
+
if (!prelude.split(',').some((entry) => entry.trim() === selector)) continue
|
|
49
|
+
for (const declaration of body.split(';')) {
|
|
50
|
+
const separator = declaration.indexOf(':')
|
|
51
|
+
if (separator === -1) continue
|
|
52
|
+
const property = declaration.slice(0, separator).trim().toLowerCase()
|
|
53
|
+
const value = cleanValue(declaration.slice(separator + 1))
|
|
54
|
+
if (property === 'flex') state = parseFlexShorthand(value)
|
|
55
|
+
if (property === 'flex-shrink') state = { grow: state?.grow ?? 0, shrink: Number(value) }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return state
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function hasMinHeightZero(selector: string): boolean {
|
|
62
|
+
return splitBlocks(CSS).some(
|
|
63
|
+
({ prelude, body }) =>
|
|
64
|
+
body !== undefined &&
|
|
65
|
+
!prelude.startsWith('@') &&
|
|
66
|
+
prelude.split(',').some((entry) => entry.trim() === selector) &&
|
|
67
|
+
/min-height:\s*0\b/.test(body),
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('participant view flex layout', () => {
|
|
72
|
+
it.each(NON_SHRINKING_ITEMS)('%s does not shrink inside a flex column', (selector) => {
|
|
73
|
+
expect(effectiveFlex(selector)?.shrink).toBe(0)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('keeps the thread scroller as the only shrinking, growing region', () => {
|
|
77
|
+
const scroller = effectiveFlex('.cv-p-thread__scroll')
|
|
78
|
+
expect(scroller?.grow).toBe(1)
|
|
79
|
+
expect(scroller?.shrink).toBe(1)
|
|
80
|
+
expect(hasMinHeightZero('.cv-p-thread__scroll')).toBe(true)
|
|
81
|
+
})
|
|
82
|
+
})
|