@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
|
@@ -0,0 +1,92 @@
|
|
|
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
|
+
// legacyStyles.fixture.css is the styles.css shipped by 0.4.2; the stylesheet is global, so anything
|
|
8
|
+
// added since must be namespaced under .cv- and must not reach the host's own elements.
|
|
9
|
+
const LEGACY_CSS = readFileSync(join(import.meta.dir, 'legacyStyles.fixture.css'), 'utf8')
|
|
10
|
+
const CURRENT_CSS = readFileSync(join(import.meta.dir, 'styles.css'), 'utf8')
|
|
11
|
+
|
|
12
|
+
const NESTED_AT_RULES = ['@media', '@supports']
|
|
13
|
+
const ALLOWED_NEW_AT_RULES = ['@media', '@supports', '@keyframes']
|
|
14
|
+
|
|
15
|
+
type Inventory = { selectors: string[]; atRules: string[]; keyframeNames: string[] }
|
|
16
|
+
|
|
17
|
+
function splitSelectorList(prelude: string): string[] {
|
|
18
|
+
const parts: string[] = []
|
|
19
|
+
let parenthesisDepth = 0
|
|
20
|
+
let current = ''
|
|
21
|
+
for (const char of prelude) {
|
|
22
|
+
if (char === '(') parenthesisDepth += 1
|
|
23
|
+
if (char === ')') parenthesisDepth -= 1
|
|
24
|
+
if (char === ',' && parenthesisDepth === 0) {
|
|
25
|
+
parts.push(current.trim())
|
|
26
|
+
current = ''
|
|
27
|
+
} else {
|
|
28
|
+
current += char
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
parts.push(current.trim())
|
|
32
|
+
return parts.filter(Boolean)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function collect(css: string, inventory: Inventory): Inventory {
|
|
36
|
+
for (const { prelude, body } of splitBlocks(css.replace(/\/\*[\s\S]*?\*\//g, ''))) {
|
|
37
|
+
if (!prelude.startsWith('@')) {
|
|
38
|
+
inventory.selectors.push(...splitSelectorList(prelude))
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
const atRuleName = prelude.split(/[\s(]/)[0] ?? prelude
|
|
42
|
+
inventory.atRules.push(atRuleName)
|
|
43
|
+
if (atRuleName === '@keyframes') inventory.keyframeNames.push(prelude.replace('@keyframes', '').trim())
|
|
44
|
+
if (NESTED_AT_RULES.includes(atRuleName) && body !== undefined) collect(body, inventory)
|
|
45
|
+
}
|
|
46
|
+
return inventory
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function inventoryOf(css: string): Inventory {
|
|
50
|
+
return collect(css, { selectors: [], atRules: [], keyframeNames: [] })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function addedSince(legacy: readonly string[], current: readonly string[]): string[] {
|
|
54
|
+
const remaining = [...legacy]
|
|
55
|
+
return current.filter((entry) => {
|
|
56
|
+
const position = remaining.indexOf(entry)
|
|
57
|
+
if (position === -1) return true
|
|
58
|
+
remaining.splice(position, 1)
|
|
59
|
+
return false
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('styles.css não vaza para o host desde a 0.4.2', () => {
|
|
64
|
+
const legacy = inventoryOf(LEGACY_CSS)
|
|
65
|
+
const current = inventoryOf(CURRENT_CSS)
|
|
66
|
+
const addedSelectors = addedSince(legacy.selectors, current.selectors)
|
|
67
|
+
const addedAtRules = addedSince(legacy.atRules, current.atRules)
|
|
68
|
+
|
|
69
|
+
it('G8: o parse enxerga as regras (guarda contra um parser que não acha nada)', () => {
|
|
70
|
+
expect(legacy.selectors.length).toBeGreaterThan(100)
|
|
71
|
+
expect(addedSelectors.length).toBeGreaterThan(20)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('G8: todo seletor adicionado começa por .cv- ou .dark .cv-', () => {
|
|
75
|
+
const offenders = addedSelectors.filter((selector) => !/^(\.dark )?\.cv-/.test(selector))
|
|
76
|
+
|
|
77
|
+
expect(offenders).toEqual([])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('G8: nenhum seletor global (:root, html, body, *) foi adicionado', () => {
|
|
81
|
+
const globals = addedSelectors.filter((selector) => /^(:root|html|body|\*)([\s.:#[>+~,]|$)/.test(selector))
|
|
82
|
+
|
|
83
|
+
expect(globals).toEqual([])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('G8: nenhum @import, @layer ou outra at-rule nova além de @media, @supports e @keyframes', () => {
|
|
87
|
+
expect(addedAtRules.filter((rule) => !ALLOWED_NEW_AT_RULES.includes(rule))).toEqual([])
|
|
88
|
+
expect(addedSince(legacy.keyframeNames, current.keyframeNames).filter((name) => !name.startsWith('cv-'))).toEqual(
|
|
89
|
+
[],
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { resolveCopyInteraction } from './messageTextCopyInteraction'
|
|
4
|
+
|
|
5
|
+
function buildKeyEvent(key: string): { key: string; preventDefault: () => void; wasPrevented: () => boolean } {
|
|
6
|
+
let prevented = false
|
|
7
|
+
return {
|
|
8
|
+
key,
|
|
9
|
+
preventDefault: () => {
|
|
10
|
+
prevented = true
|
|
11
|
+
},
|
|
12
|
+
wasPrevented: () => prevented,
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('resolveCopyInteraction', () => {
|
|
17
|
+
it('por padrão devolve só o onClick de cópia', () => {
|
|
18
|
+
const onCopy = () => undefined
|
|
19
|
+
const props = resolveCopyInteraction({ copyOnClick: true, accessibleCopy: false, onCopy })
|
|
20
|
+
|
|
21
|
+
expect(props.onClick).toBe(onCopy)
|
|
22
|
+
expect('role' in props).toBe(false)
|
|
23
|
+
expect('tabIndex' in props).toBe(false)
|
|
24
|
+
expect('onKeyDown' in props).toBe(false)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('com copyOnClick desligado devolve objeto vazio, mesmo com accessibleCopy', () => {
|
|
28
|
+
expect(resolveCopyInteraction({ copyOnClick: false, accessibleCopy: false, onCopy: () => undefined })).toEqual({})
|
|
29
|
+
expect(resolveCopyInteraction({ copyOnClick: false, accessibleCopy: true, onCopy: () => undefined })).toEqual({})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('com accessibleCopy expõe role, tabIndex e o onClick', () => {
|
|
33
|
+
const onCopy = () => undefined
|
|
34
|
+
const props = resolveCopyInteraction({ copyOnClick: true, accessibleCopy: true, onCopy })
|
|
35
|
+
|
|
36
|
+
expect(props.role).toBe('button')
|
|
37
|
+
expect(props.tabIndex).toBe(0)
|
|
38
|
+
expect(props.onClick).toBe(onCopy)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('Enter e Espaço copiam e previnem o default', () => {
|
|
42
|
+
let copies = 0
|
|
43
|
+
const props = resolveCopyInteraction({
|
|
44
|
+
copyOnClick: true,
|
|
45
|
+
accessibleCopy: true,
|
|
46
|
+
onCopy: () => {
|
|
47
|
+
copies += 1
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
for (const key of ['Enter', ' ']) {
|
|
52
|
+
const event = buildKeyEvent(key)
|
|
53
|
+
props.onKeyDown?.(event)
|
|
54
|
+
expect(event.wasPrevented()).toBe(true)
|
|
55
|
+
}
|
|
56
|
+
expect(copies).toBe(2)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('outras teclas não copiam nem previnem o default', () => {
|
|
60
|
+
let copies = 0
|
|
61
|
+
const props = resolveCopyInteraction({
|
|
62
|
+
copyOnClick: true,
|
|
63
|
+
accessibleCopy: true,
|
|
64
|
+
onCopy: () => {
|
|
65
|
+
copies += 1
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
const event = buildKeyEvent('Tab')
|
|
69
|
+
|
|
70
|
+
props.onKeyDown?.(event)
|
|
71
|
+
|
|
72
|
+
expect(copies).toBe(0)
|
|
73
|
+
expect(event.wasPrevented()).toBe(false)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type CopyKeyboardEvent = {
|
|
2
|
+
readonly key: string
|
|
3
|
+
preventDefault: () => void
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export type CopyInteractionProps = {
|
|
7
|
+
onClick?: () => void
|
|
8
|
+
role?: 'button'
|
|
9
|
+
tabIndex?: 0
|
|
10
|
+
onKeyDown?: (event: CopyKeyboardEvent) => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type ResolveCopyInteractionOptions = {
|
|
14
|
+
readonly copyOnClick: boolean
|
|
15
|
+
readonly accessibleCopy: boolean
|
|
16
|
+
readonly onCopy: () => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolveCopyInteraction({
|
|
20
|
+
copyOnClick,
|
|
21
|
+
accessibleCopy,
|
|
22
|
+
onCopy,
|
|
23
|
+
}: ResolveCopyInteractionOptions): CopyInteractionProps {
|
|
24
|
+
if (!copyOnClick) return {}
|
|
25
|
+
if (!accessibleCopy) return { onClick: onCopy }
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
onClick: onCopy,
|
|
29
|
+
role: 'button',
|
|
30
|
+
tabIndex: 0,
|
|
31
|
+
onKeyDown: (event) => {
|
|
32
|
+
if (event.key !== 'Enter' && event.key !== ' ') return
|
|
33
|
+
event.preventDefault()
|
|
34
|
+
onCopy()
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { ParticipantChannelBadges } from './ParticipantChannelBadges'
|
|
5
|
+
import { findUtilityClassTokens } from './participantFixtures.test-helper'
|
|
6
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
7
|
+
|
|
8
|
+
const render = (channels: readonly string[] | undefined, variant?: 'list' | 'inline') =>
|
|
9
|
+
renderToStaticMarkup(
|
|
10
|
+
<ParticipantChannelBadges
|
|
11
|
+
channels={channels}
|
|
12
|
+
labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS}
|
|
13
|
+
{...(variant === undefined ? {} : { variant })}
|
|
14
|
+
/>,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
describe('ParticipantChannelBadges', () => {
|
|
18
|
+
it('draws nothing without channels, not even the list', () => {
|
|
19
|
+
expect(render(undefined)).toBe('')
|
|
20
|
+
expect(render([])).toBe('')
|
|
21
|
+
expect(render(['sms'])).toBe('')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('draws one badge per channel in a stable order with accessible text', () => {
|
|
25
|
+
const markup = render(['whatsapp', 'app'])
|
|
26
|
+
|
|
27
|
+
expect(markup).toContain('<ul class="cv-p-channels"')
|
|
28
|
+
expect(markup).toContain('aria-label="Channels in this conversation"')
|
|
29
|
+
expect(markup.indexOf('cv-p-channel--app')).toBeLessThan(markup.indexOf('cv-p-channel--whatsapp'))
|
|
30
|
+
expect(markup).toContain('<span class="cv-p-sr-only">App</span>')
|
|
31
|
+
expect(markup).toContain('<span class="cv-p-sr-only">WhatsApp</span>')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('hides the icons from assistive tech and uses no utility classes', () => {
|
|
35
|
+
const markup = render(['app', 'whatsapp', 'email', 'portal', 'webchat'])
|
|
36
|
+
|
|
37
|
+
expect(markup.match(/<svg[^>]*aria-hidden="true"/g)).toHaveLength(5)
|
|
38
|
+
expect(findUtilityClassTokens(markup)).toEqual([])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('inline variant draws spans only, with a group prefix and audible separators', () => {
|
|
42
|
+
const markup = render(['whatsapp', 'app', 'app'], 'inline')
|
|
43
|
+
|
|
44
|
+
expect(markup).not.toContain('<ul')
|
|
45
|
+
expect(markup).not.toContain('<li')
|
|
46
|
+
expect(markup).toContain('<span class="cv-p-channels">')
|
|
47
|
+
expect(markup).toContain('<span class="cv-p-sr-only">Channels in this conversation: </span>')
|
|
48
|
+
expect(markup.match(/class="cv-p-channel cv-p-channel--/g)).toHaveLength(2)
|
|
49
|
+
expect(markup.indexOf('cv-p-channel--app')).toBeLessThan(markup.indexOf('cv-p-channel--whatsapp'))
|
|
50
|
+
expect(markup).toContain('<span class="cv-p-sr-only">App, </span>')
|
|
51
|
+
expect(markup).toContain('<span class="cv-p-sr-only">WhatsApp</span>')
|
|
52
|
+
expect(markup.match(/<svg[^>]*aria-hidden="true"/g)).toHaveLength(2)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('inline variant draws nothing without valid channels', () => {
|
|
56
|
+
expect(render(undefined, 'inline')).toBe('')
|
|
57
|
+
expect(render(['sms'], 'inline')).toBe('')
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Globe, Mail, MessageCircle, MessagesSquare, Smartphone } from 'lucide-react'
|
|
2
|
+
import type { ComponentType } from 'react'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
resolveParticipantChannelLabel,
|
|
6
|
+
resolveParticipantChannels,
|
|
7
|
+
type ParticipantChannelDescriptor,
|
|
8
|
+
} from './participantChannels'
|
|
9
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
10
|
+
|
|
11
|
+
const ICONS: Record<
|
|
12
|
+
ParticipantChannelDescriptor['iconName'],
|
|
13
|
+
ComponentType<{ size?: number; 'aria-hidden'?: boolean }>
|
|
14
|
+
> = {
|
|
15
|
+
Smartphone,
|
|
16
|
+
MessageCircle,
|
|
17
|
+
Mail,
|
|
18
|
+
Globe,
|
|
19
|
+
MessagesSquare,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ParticipantChannelBadgesProps = {
|
|
23
|
+
readonly channels: readonly string[] | undefined
|
|
24
|
+
readonly labels: ParticipantConversationsLabels
|
|
25
|
+
/** 'list' draws ul/li; 'inline' draws spans only, valid inside a button. */
|
|
26
|
+
readonly variant?: 'list' | 'inline'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type BadgeProps = {
|
|
30
|
+
readonly descriptor: ParticipantChannelDescriptor
|
|
31
|
+
readonly labels: ParticipantConversationsLabels
|
|
32
|
+
readonly separator: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function BadgeContent({ descriptor, labels, separator }: BadgeProps) {
|
|
36
|
+
const Icon = ICONS[descriptor.iconName]
|
|
37
|
+
return (
|
|
38
|
+
<>
|
|
39
|
+
<Icon size={14} aria-hidden={true} />
|
|
40
|
+
<span className="cv-p-sr-only">{`${resolveParticipantChannelLabel(descriptor, labels)}${separator}`}</span>
|
|
41
|
+
</>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function ParticipantChannelBadges({ channels, labels, variant = 'list' }: ParticipantChannelBadgesProps) {
|
|
46
|
+
const descriptors = resolveParticipantChannels(channels)
|
|
47
|
+
if (descriptors.length === 0) return null
|
|
48
|
+
|
|
49
|
+
if (variant === 'inline') {
|
|
50
|
+
return (
|
|
51
|
+
<span className="cv-p-channels">
|
|
52
|
+
<span className="cv-p-sr-only">{`${labels.channelsGroup}: `}</span>
|
|
53
|
+
{descriptors.map((descriptor, index) => (
|
|
54
|
+
<span key={descriptor.channel} className={`cv-p-channel cv-p-channel--${descriptor.channel}`}>
|
|
55
|
+
<BadgeContent
|
|
56
|
+
descriptor={descriptor}
|
|
57
|
+
labels={labels}
|
|
58
|
+
separator={index < descriptors.length - 1 ? ', ' : ''}
|
|
59
|
+
/>
|
|
60
|
+
</span>
|
|
61
|
+
))}
|
|
62
|
+
</span>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<ul className="cv-p-channels" aria-label={labels.channelsGroup}>
|
|
68
|
+
{descriptors.map((descriptor) => (
|
|
69
|
+
<li key={descriptor.channel} className={`cv-p-channel cv-p-channel--${descriptor.channel}`}>
|
|
70
|
+
<BadgeContent descriptor={descriptor} labels={labels} separator="" />
|
|
71
|
+
</li>
|
|
72
|
+
))}
|
|
73
|
+
</ul>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
2
|
|
|
3
|
-
import type {
|
|
4
|
-
ParticipantConversationSummary,
|
|
5
|
-
ParticipantSubjectRef,
|
|
6
|
-
} from '@adatechnology/conversation-contracts'
|
|
3
|
+
import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
7
4
|
|
|
8
5
|
import type { ConversationChannel } from '../conversationChannel'
|
|
9
6
|
import type { QuickReply } from '../quickReplies/quickReply.types'
|
|
10
7
|
import type { ConversationsTheme } from '../types'
|
|
11
8
|
import { ParticipantConversationsScreen } from './ParticipantConversationsScreen'
|
|
12
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
ParticipantConversationsClassNames,
|
|
11
|
+
ParticipantSubjectGroup,
|
|
12
|
+
ParticipantSubjectIconRenderer,
|
|
13
|
+
} from './participant.types'
|
|
13
14
|
import type { ParticipantConversationsApi, ParticipantPendingMessage } from './participantApi.types'
|
|
14
15
|
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
15
16
|
import { useParticipantInbox } from './useParticipantInbox'
|
|
@@ -30,6 +31,7 @@ export type ParticipantConversationsProps = {
|
|
|
30
31
|
pendingMessages?: readonly ParticipantPendingMessage[]
|
|
31
32
|
onRetryPending?: (clientMessageId: string) => void
|
|
32
33
|
quickReplies?: readonly QuickReply[]
|
|
34
|
+
renderSubjectIcon?: ParticipantSubjectIconRenderer
|
|
33
35
|
renderSubjectCard?: (conversation: ParticipantConversationSummary) => ReactNode
|
|
34
36
|
onOpenSubject?: (subject: ParticipantSubjectRef) => void
|
|
35
37
|
}
|
|
@@ -38,5 +40,10 @@ export function ParticipantConversations(props: ParticipantConversationsProps) {
|
|
|
38
40
|
const inbox = useParticipantInbox(props.api)
|
|
39
41
|
const { status, conversations, hasMore, refresh, loadMore, markSubjectRead } = inbox
|
|
40
42
|
|
|
41
|
-
return
|
|
43
|
+
return (
|
|
44
|
+
<ParticipantConversationsScreen
|
|
45
|
+
{...props}
|
|
46
|
+
inbox={{ status, conversations, hasMore, refresh, loadMore, markSubjectRead }}
|
|
47
|
+
/>
|
|
48
|
+
)
|
|
42
49
|
}
|
|
@@ -6,12 +6,16 @@ import { ParticipantThreadScreen } from './ParticipantThreadScreen'
|
|
|
6
6
|
import { draftKey, type ParticipantDrafts } from './participantDrafts'
|
|
7
7
|
import { groupParticipantConversations } from './participantGrouping'
|
|
8
8
|
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
9
|
+
import { isInboxSearchVisible } from './participantProtocol'
|
|
9
10
|
import { participantSendStatesReducer } from './participantSendStates'
|
|
10
11
|
import { buildParticipantThemeStyle } from './participantTheme'
|
|
11
12
|
import type { UseParticipantInboxResult } from './useParticipantInbox'
|
|
12
13
|
|
|
13
14
|
export type ParticipantConversationsScreenProps = ParticipantConversationsProps & {
|
|
14
|
-
readonly inbox: Pick<
|
|
15
|
+
readonly inbox: Pick<
|
|
16
|
+
UseParticipantInboxResult,
|
|
17
|
+
'status' | 'conversations' | 'hasMore' | 'refresh' | 'loadMore' | 'markSubjectRead'
|
|
18
|
+
>
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
const ALL_FILTER = 'all'
|
|
@@ -19,16 +23,21 @@ const ALL_FILTER = 'all'
|
|
|
19
23
|
export function ParticipantConversationsScreen(props: ParticipantConversationsScreenProps) {
|
|
20
24
|
const { selected, inbox, subjectGroups, classNames } = props
|
|
21
25
|
const [filter, setFilter] = useState(ALL_FILTER)
|
|
26
|
+
const [searchQuery, setSearchQuery] = useState('')
|
|
22
27
|
const draftsRef = useRef<ParticipantDrafts>(new Map())
|
|
23
28
|
const [sendStates, dispatchSendStates] = useReducer(participantSendStatesReducer, new Map())
|
|
24
|
-
const labels = useMemo(
|
|
25
|
-
() => ({ ...DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, ...props.labels }),
|
|
26
|
-
[props.labels],
|
|
27
|
-
)
|
|
29
|
+
const labels = useMemo(() => ({ ...DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, ...props.labels }), [props.labels])
|
|
28
30
|
const view = useMemo(
|
|
29
|
-
() =>
|
|
30
|
-
|
|
31
|
+
() =>
|
|
32
|
+
groupParticipantConversations({
|
|
33
|
+
conversations: inbox.conversations,
|
|
34
|
+
subjectGroups,
|
|
35
|
+
filter,
|
|
36
|
+
searchQuery,
|
|
37
|
+
}),
|
|
38
|
+
[inbox.conversations, subjectGroups, filter, searchQuery],
|
|
31
39
|
)
|
|
40
|
+
const isSearchVisible = isInboxSearchVisible({ conversations: inbox.conversations, searchQuery })
|
|
32
41
|
const rootClassName = ['cv-p-root', props.className, classNames?.root].filter(Boolean).join(' ')
|
|
33
42
|
|
|
34
43
|
return (
|
|
@@ -47,6 +56,8 @@ export function ParticipantConversationsScreen(props: ParticipantConversationsSc
|
|
|
47
56
|
hasMore={inbox.hasMore}
|
|
48
57
|
loadMore={() => void inbox.loadMore()}
|
|
49
58
|
locale={props.locale}
|
|
59
|
+
renderSubjectIcon={props.renderSubjectIcon}
|
|
60
|
+
search={{ value: searchQuery, onChange: setSearchQuery, isVisible: isSearchVisible }}
|
|
50
61
|
/>
|
|
51
62
|
</div>
|
|
52
63
|
) : (
|
|
@@ -5,7 +5,7 @@ import { buildConversation, findForeignClassTokens, findUtilityClassTokens } fro
|
|
|
5
5
|
import { groupParticipantConversations } from './participantGrouping'
|
|
6
6
|
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
7
7
|
import type { ParticipantSubjectGroup } from './participant.types'
|
|
8
|
-
import { ParticipantInbox } from './ParticipantInbox'
|
|
8
|
+
import { ParticipantInbox, type ParticipantInboxProps } from './ParticipantInbox'
|
|
9
9
|
|
|
10
10
|
const GROUPS: readonly ParticipantSubjectGroup[] = [
|
|
11
11
|
{ subjectType: 'invoice', label: 'Invoice' },
|
|
@@ -13,7 +13,13 @@ const GROUPS: readonly ParticipantSubjectGroup[] = [
|
|
|
13
13
|
]
|
|
14
14
|
|
|
15
15
|
const CONVERSATIONS = [
|
|
16
|
-
buildConversation({
|
|
16
|
+
buildConversation({
|
|
17
|
+
subjectId: '1',
|
|
18
|
+
subjectLabel: 'Awaiting one',
|
|
19
|
+
awaitingParticipant: true,
|
|
20
|
+
unreadCount: 2,
|
|
21
|
+
lastMessagePreview: 'Please send the photo',
|
|
22
|
+
}),
|
|
17
23
|
buildConversation({ subjectId: '2', subjectLabel: 'Invoice two' }),
|
|
18
24
|
buildConversation({ subjectId: '3', subjectType: 'incident', subjectLabel: 'Incident three' }),
|
|
19
25
|
buildConversation({ subjectId: '4', subjectLabel: 'Old one', status: 'closed' }),
|
|
@@ -24,6 +30,7 @@ type RenderOverrides = {
|
|
|
24
30
|
filter?: string
|
|
25
31
|
status?: 'idle' | 'loading' | 'ready' | 'error'
|
|
26
32
|
hasMore?: boolean
|
|
33
|
+
renderSubjectIcon?: ParticipantInboxProps['renderSubjectIcon']
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
function render(overrides: RenderOverrides = {}): string {
|
|
@@ -44,10 +51,100 @@ function render(overrides: RenderOverrides = {}): string {
|
|
|
44
51
|
refresh={() => undefined}
|
|
45
52
|
hasMore={overrides.hasMore ?? false}
|
|
46
53
|
loadMore={() => undefined}
|
|
54
|
+
renderSubjectIcon={overrides.renderSubjectIcon}
|
|
47
55
|
/>,
|
|
48
56
|
)
|
|
49
57
|
}
|
|
50
58
|
|
|
59
|
+
describe('ParticipantInbox channels and subject icon', () => {
|
|
60
|
+
const withChannels = [
|
|
61
|
+
buildConversation({ subjectId: '9', subjectLabel: 'Chatty', channels: ['whatsapp', 'app'], iconName: 'file-text' }),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
it('shows channel badges only when the conversation has channels', () => {
|
|
65
|
+
expect(render({ conversations: withChannels })).toContain('cv-p-channel--app')
|
|
66
|
+
expect(render()).not.toContain('cv-p-channels')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('keeps the badge order stable and the accessible text present', () => {
|
|
70
|
+
const markup = render({ conversations: withChannels })
|
|
71
|
+
|
|
72
|
+
expect(markup.indexOf('cv-p-channel--app')).toBeLessThan(markup.indexOf('cv-p-channel--whatsapp'))
|
|
73
|
+
expect(markup).toContain('<span class="cv-p-sr-only">WhatsApp</span>')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('row badges are phrasing content: no list inside the row button, header keeps its list', () => {
|
|
77
|
+
const markup = render({ conversations: withChannels })
|
|
78
|
+
const button = markup.slice(markup.indexOf('<button'), markup.indexOf('</button>'))
|
|
79
|
+
|
|
80
|
+
expect(button).toContain('cv-p-channels')
|
|
81
|
+
expect(button).not.toContain('<ul')
|
|
82
|
+
expect(button).not.toContain('<li')
|
|
83
|
+
expect(button).toContain('Channels in this conversation: ')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('treats false and empty string from renderSubjectIcon as absent', () => {
|
|
87
|
+
const groups = [{ subjectType: 'invoice', label: 'Invoice', icon: 'GRP' }]
|
|
88
|
+
const view = groupParticipantConversations({ conversations: withChannels, subjectGroups: groups })
|
|
89
|
+
const renderWith = (renderSubjectIcon: ParticipantInboxProps['renderSubjectIcon']) =>
|
|
90
|
+
renderToStaticMarkup(
|
|
91
|
+
<ParticipantInbox
|
|
92
|
+
view={view}
|
|
93
|
+
subjectGroups={groups}
|
|
94
|
+
filter="all"
|
|
95
|
+
onFilterChange={() => undefined}
|
|
96
|
+
onSelect={() => undefined}
|
|
97
|
+
labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS}
|
|
98
|
+
status="ready"
|
|
99
|
+
refresh={() => undefined}
|
|
100
|
+
hasMore={false}
|
|
101
|
+
loadMore={() => undefined}
|
|
102
|
+
renderSubjectIcon={renderSubjectIcon}
|
|
103
|
+
/>,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
expect(renderWith(() => false)).toContain('GRP')
|
|
107
|
+
expect(renderWith(() => '')).toContain('GRP')
|
|
108
|
+
expect(renderWith(() => <i data-custom="1" />)).not.toContain('GRP')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('uses renderSubjectIcon when it returns a node', () => {
|
|
112
|
+
const markup = render({
|
|
113
|
+
renderSubjectIcon: (conversation) => <i data-icon={conversation.iconName ?? 'none'} />,
|
|
114
|
+
conversations: withChannels,
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
expect(markup).toContain('cv-p-inbox__icon')
|
|
118
|
+
expect(markup).toContain('data-icon="file-text"')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('falls back to the group icon when renderSubjectIcon returns null or undefined', () => {
|
|
122
|
+
const groups = [{ subjectType: 'invoice', label: 'Invoice', icon: 'GRP' }]
|
|
123
|
+
const view = groupParticipantConversations({ conversations: withChannels, subjectGroups: groups })
|
|
124
|
+
const renderWith = (renderSubjectIcon: ParticipantInboxProps['renderSubjectIcon']) =>
|
|
125
|
+
renderToStaticMarkup(
|
|
126
|
+
<ParticipantInbox
|
|
127
|
+
view={view}
|
|
128
|
+
subjectGroups={groups}
|
|
129
|
+
filter="all"
|
|
130
|
+
onFilterChange={() => undefined}
|
|
131
|
+
onSelect={() => undefined}
|
|
132
|
+
labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS}
|
|
133
|
+
status="ready"
|
|
134
|
+
refresh={() => undefined}
|
|
135
|
+
hasMore={false}
|
|
136
|
+
loadMore={() => undefined}
|
|
137
|
+
renderSubjectIcon={renderSubjectIcon}
|
|
138
|
+
/>,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
expect(renderWith(() => null)).toContain('GRP')
|
|
142
|
+
expect(renderWith(() => undefined)).toContain('GRP')
|
|
143
|
+
expect(renderWith(undefined)).toContain('GRP')
|
|
144
|
+
expect(renderWith(() => <i data-custom="1" />)).not.toContain('GRP')
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
51
148
|
describe('ParticipantInbox', () => {
|
|
52
149
|
it('renders section headers with label and count, awaiting highlighted', () => {
|
|
53
150
|
const markup = render()
|