@adatechnology/conversations-ui 0.4.2 → 0.6.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.
- package/README.md +231 -0
- package/dist/{ConversationSimulatorPanel-DnXElfY8.d.ts → ConversationSimulatorPanel-CrfFjiWh.d.ts} +2 -268
- package/dist/{chunk-PJNR6UMN.js → chunk-CBYEHPX6.js} +1 -63
- package/dist/{chunk-4P5KMWOI.js → chunk-FAQLC3TX.js} +327 -522
- package/dist/{chunk-HMCOTZAX.js → chunk-HOKNKXIT.js} +1 -1
- package/dist/chunk-MRVFFJEH.js +64 -0
- package/dist/chunk-O6457XHK.js +255 -0
- package/dist/chunk-OVFCLRQQ.js +84 -0
- package/dist/flows/index.js +5 -3
- package/dist/index.d.ts +21 -5
- package/dist/index.js +783 -807
- package/dist/participant/index.d.ts +186 -0
- package/dist/participant/index.js +1708 -0
- package/dist/preview/index.d.ts +4 -2
- package/dist/preview/index.js +4 -2
- package/dist/quickReply.types-B4gmZsSr.d.ts +274 -0
- package/dist/styles.css +689 -0
- package/package.json +8 -4
- package/src/ChannelIcon.tsx +2 -0
- package/src/MessageText.test.tsx +76 -0
- package/src/MessageText.tsx +28 -12
- package/src/MessageTextCopiedBadge.tsx +15 -0
- package/src/StatusTicks.test.tsx +51 -0
- package/src/StatusTicks.tsx +36 -6
- package/src/buildOutput.test.ts +41 -0
- package/src/channelCapability.test.ts +17 -0
- package/src/conversationChannel.test.ts +30 -1
- package/src/conversationChannel.ts +19 -1
- 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 +121 -0
- package/src/messageTextCopyInteraction.test.ts +75 -0
- package/src/messageTextCopyInteraction.ts +37 -0
- package/src/participant/ParticipantAttachmentItem.tsx +77 -0
- package/src/participant/ParticipantChannelBadges.test.tsx +59 -0
- package/src/participant/ParticipantChannelBadges.tsx +75 -0
- package/src/participant/ParticipantComposer.test.tsx +97 -0
- package/src/participant/ParticipantComposer.tsx +167 -0
- package/src/participant/ParticipantConversations.test.tsx +90 -0
- package/src/participant/ParticipantConversations.tsx +49 -0
- package/src/participant/ParticipantConversationsScreen.tsx +78 -0
- package/src/participant/ParticipantInbox.test.tsx +244 -0
- package/src/participant/ParticipantInbox.tsx +149 -0
- package/src/participant/ParticipantInboxRow.tsx +75 -0
- package/src/participant/ParticipantInboxSearchField.tsx +34 -0
- package/src/participant/ParticipantLoadState.tsx +27 -0
- package/src/participant/ParticipantMessageBubble.test.tsx +167 -0
- package/src/participant/ParticipantMessageBubble.tsx +185 -0
- package/src/participant/ParticipantProtocolBadge.tsx +74 -0
- package/src/participant/ParticipantProtocolView.test.tsx +208 -0
- package/src/participant/ParticipantThread.test.tsx +149 -0
- package/src/participant/ParticipantThread.tsx +184 -0
- package/src/participant/ParticipantThreadScreen.tsx +135 -0
- package/src/participant/bindAttachmentUrlResolver.test.ts +25 -0
- package/src/participant/bindAttachmentUrlResolver.ts +7 -0
- package/src/participant/index.ts +26 -0
- package/src/participant/participant.types.ts +52 -0
- package/src/participant/participantApi.types.ts +59 -0
- package/src/participant/participantAttachmentUrl.test.ts +32 -0
- package/src/participant/participantAttachmentUrl.ts +10 -0
- package/src/participant/participantBubbleActions.test.ts +44 -0
- package/src/participant/participantBubbleActions.ts +40 -0
- package/src/participant/participantChannels.test.ts +42 -0
- package/src/participant/participantChannels.ts +33 -0
- package/src/participant/participantDrafts.test.ts +65 -0
- package/src/participant/participantDrafts.ts +50 -0
- package/src/participant/participantFileValidation.test.ts +86 -0
- package/src/participant/participantFileValidation.ts +72 -0
- package/src/participant/participantFixtures.test-helper.ts +41 -0
- package/src/participant/participantGrouping.test.ts +236 -0
- package/src/participant/participantGrouping.ts +90 -0
- package/src/participant/participantInboxState.test.ts +69 -0
- package/src/participant/participantInboxState.ts +74 -0
- package/src/participant/participantLabels.ts +115 -0
- package/src/participant/participantLoadView.test.ts +23 -0
- package/src/participant/participantLoadView.ts +21 -0
- package/src/participant/participantMessages.test.ts +128 -0
- package/src/participant/participantMessages.ts +73 -0
- package/src/participant/participantNewMessages.test.ts +54 -0
- package/src/participant/participantNewMessages.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/participant/participantQuickReply.test.ts +23 -0
- package/src/participant/participantQuickReply.ts +6 -0
- package/src/participant/participantScroll.test.ts +69 -0
- package/src/participant/participantScroll.ts +43 -0
- package/src/participant/participantSendController.test.ts +201 -0
- package/src/participant/participantSendController.ts +134 -0
- package/src/participant/participantSendStates.ts +31 -0
- package/src/participant/participantTheme.ts +20 -0
- package/src/participant/participantThreadState.test.ts +75 -0
- package/src/participant/participantThreadState.ts +59 -0
- package/src/participant/shouldMarkRead.test.ts +24 -0
- package/src/participant/shouldMarkRead.ts +16 -0
- package/src/participant/useDocumentVisibility.ts +18 -0
- package/src/participant/useParticipantConversationView.ts +60 -0
- package/src/participant/useParticipantDraft.ts +45 -0
- package/src/participant/useParticipantInbox.ts +57 -0
- package/src/participant/useParticipantMarkRead.ts +26 -0
- package/src/participant/useParticipantRevalidation.ts +36 -0
- package/src/participant/useParticipantSend.ts +84 -0
- package/src/participant/useParticipantThread.ts +116 -0
- package/src/participant/useParticipantThreadScroll.ts +26 -0
- package/src/participant/useStickToBottom.ts +77 -0
- package/src/preview/ConversationSimulatorPanel.tsx +8 -0
- package/src/styles.css +178 -0
- package/src/types.ts +3 -1
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { useRef, useState, type ChangeEvent, type FormEvent } from 'react'
|
|
2
|
+
|
|
3
|
+
import { channelCapabilityFor } from '../channelCapability'
|
|
4
|
+
import type { ConversationChannel } from '../conversationChannel'
|
|
5
|
+
import { formatFileSize } from '../lib/format'
|
|
6
|
+
import type { QuickReply } from '../quickReplies/quickReply.types'
|
|
7
|
+
import { maxBytesForChannel, reduceFilesSelection, type ParticipantFileRejection } from './participantFileValidation'
|
|
8
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
9
|
+
import { applyQuickReply } from './participantQuickReply'
|
|
10
|
+
|
|
11
|
+
export type ParticipantComposerProps = {
|
|
12
|
+
readonly value: string
|
|
13
|
+
readonly onChange: (value: string) => void
|
|
14
|
+
readonly files: readonly File[]
|
|
15
|
+
readonly onFilesChange: (files: readonly File[]) => void
|
|
16
|
+
readonly onSend: () => void
|
|
17
|
+
readonly labels: ParticipantConversationsLabels
|
|
18
|
+
/** Absent means the `app` channel. */
|
|
19
|
+
readonly channel?: ConversationChannel
|
|
20
|
+
readonly disabled?: boolean
|
|
21
|
+
/** Blocks only the send button: the field stays editable while a send is in flight. */
|
|
22
|
+
readonly isSending?: boolean
|
|
23
|
+
readonly maxLength?: number
|
|
24
|
+
/** MIME types or wildcards offered to the file picker and enforced before sending. */
|
|
25
|
+
readonly acceptedTypes?: readonly string[]
|
|
26
|
+
/** Tapping a chip fills the text; it never sends. Absent or empty means no strip. */
|
|
27
|
+
readonly quickReplies?: readonly QuickReply[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type QuickReplyStripProps = {
|
|
31
|
+
readonly quickReplies: readonly QuickReply[]
|
|
32
|
+
readonly labels: ParticipantConversationsLabels
|
|
33
|
+
readonly onPick: (body: string) => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function QuickReplyStrip({ quickReplies, labels, onPick }: QuickReplyStripProps) {
|
|
37
|
+
return (
|
|
38
|
+
<div className="cv-p-quick" role="group" aria-label={labels.quickRepliesGroup}>
|
|
39
|
+
{quickReplies.map((quickReply) => (
|
|
40
|
+
<button key={quickReply.id} type="button" className="cv-p-chip" onClick={() => onPick(quickReply.body)}>
|
|
41
|
+
{quickReply.title}
|
|
42
|
+
</button>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type FileListProps = {
|
|
49
|
+
readonly files: readonly File[]
|
|
50
|
+
readonly labels: ParticipantConversationsLabels
|
|
51
|
+
readonly onRemove: (file: File) => void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function FileList({ files, labels, onRemove }: FileListProps) {
|
|
55
|
+
if (files.length === 0) return null
|
|
56
|
+
return (
|
|
57
|
+
<ul className="cv-p-files" aria-label={labels.attachmentsList}>
|
|
58
|
+
{files.map((file, index) => (
|
|
59
|
+
<li key={`${file.name}-${file.size}-${index}`} className="cv-p-files__item">
|
|
60
|
+
<span className="cv-p-files__name">{file.name}</span>
|
|
61
|
+
<span className="cv-p-files__size">{formatFileSize(file.size)}</span>
|
|
62
|
+
<button type="button" className="cv-p-files__remove" aria-label={`${labels.removeAttachment}: ${file.name}`} onClick={() => onRemove(file)}>
|
|
63
|
+
<span aria-hidden="true">×</span>
|
|
64
|
+
<span className="cv-p-sr-only">{labels.removeAttachment}</span>
|
|
65
|
+
</button>
|
|
66
|
+
</li>
|
|
67
|
+
))}
|
|
68
|
+
</ul>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function rejectionText(rejection: ParticipantFileRejection, labels: ParticipantConversationsLabels): string {
|
|
73
|
+
return rejection === 'tooLarge' ? labels.attachmentTooLarge : labels.attachmentTypeNotAccepted
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type AttachControlProps = {
|
|
77
|
+
readonly labels: ParticipantConversationsLabels
|
|
78
|
+
readonly disabled: boolean
|
|
79
|
+
readonly acceptedTypes?: readonly string[]
|
|
80
|
+
readonly onChosen: (event: ChangeEvent<HTMLInputElement>) => void
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function AttachControl({ labels, disabled, acceptedTypes, onChosen }: AttachControlProps) {
|
|
84
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
85
|
+
return (
|
|
86
|
+
<>
|
|
87
|
+
<input
|
|
88
|
+
ref={fileInputRef}
|
|
89
|
+
className="cv-p-composer__file-input"
|
|
90
|
+
type="file"
|
|
91
|
+
multiple
|
|
92
|
+
hidden
|
|
93
|
+
accept={acceptedTypes?.join(',')}
|
|
94
|
+
onChange={onChosen}
|
|
95
|
+
/>
|
|
96
|
+
<button type="button" className="cv-p-button" disabled={disabled} onClick={() => fileInputRef.current?.click()}>
|
|
97
|
+
<span aria-hidden="true">+</span>
|
|
98
|
+
<span className="cv-p-sr-only">{labels.attach}</span>
|
|
99
|
+
</button>
|
|
100
|
+
</>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function useFileSelection(props: ParticipantComposerProps) {
|
|
105
|
+
const [rejection, setRejection] = useState<ParticipantFileRejection | undefined>(undefined)
|
|
106
|
+
const { files, onFilesChange, channel } = props
|
|
107
|
+
const maxBytes = maxBytesForChannel(channel)
|
|
108
|
+
|
|
109
|
+
function handleFilesChosen(event: ChangeEvent<HTMLInputElement>): void {
|
|
110
|
+
const chosen = Array.from(event.target.files ?? [])
|
|
111
|
+
event.target.value = ''
|
|
112
|
+
const result = reduceFilesSelection({ current: files, incoming: chosen, maxBytes, acceptedTypes: props.acceptedTypes })
|
|
113
|
+
setRejection(result.rejected[0]?.reason)
|
|
114
|
+
if (result.files.length !== files.length) onFilesChange(result.files)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function handleRemoveFile(file: File): void {
|
|
118
|
+
setRejection(undefined)
|
|
119
|
+
onFilesChange(files.filter((current) => current !== file))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { rejection, handleFilesChosen, handleRemoveFile }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function ParticipantComposer(props: ParticipantComposerProps) {
|
|
126
|
+
const { value, onChange, files, onSend, labels, channel = 'app', disabled = false, isSending = false } = props
|
|
127
|
+
const { rejection, handleFilesChosen, handleRemoveFile } = useFileSelection(props)
|
|
128
|
+
const canSend = !disabled && !isSending && (value.trim().length > 0 || files.length > 0)
|
|
129
|
+
|
|
130
|
+
function handleSubmit(event: FormEvent<HTMLFormElement>): void {
|
|
131
|
+
event.preventDefault()
|
|
132
|
+
if (canSend) onSend()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<form className="cv-p-composer" onSubmit={handleSubmit}>
|
|
137
|
+
{props.quickReplies && props.quickReplies.length > 0 ? (
|
|
138
|
+
<QuickReplyStrip quickReplies={props.quickReplies} labels={labels} onPick={(body) => onChange(applyQuickReply(value, body))} />
|
|
139
|
+
) : null}
|
|
140
|
+
<FileList files={files} labels={labels} onRemove={handleRemoveFile} />
|
|
141
|
+
{rejection ? (
|
|
142
|
+
<p className="cv-p-composer__error" role="alert">
|
|
143
|
+
{rejectionText(rejection, labels)}
|
|
144
|
+
</p>
|
|
145
|
+
) : null}
|
|
146
|
+
<div className="cv-p-composer__row">
|
|
147
|
+
{channelCapabilityFor(channel).attachments.accepted ? (
|
|
148
|
+
<AttachControl labels={labels} disabled={disabled} acceptedTypes={props.acceptedTypes} onChosen={handleFilesChosen} />
|
|
149
|
+
) : null}
|
|
150
|
+
<textarea
|
|
151
|
+
className="cv-p-composer__input"
|
|
152
|
+
aria-label={labels.messageInputLabel}
|
|
153
|
+
placeholder={labels.messageInputPlaceholder}
|
|
154
|
+
rows={1}
|
|
155
|
+
value={value}
|
|
156
|
+
maxLength={props.maxLength}
|
|
157
|
+
disabled={disabled}
|
|
158
|
+
onChange={(event) => onChange(event.target.value)}
|
|
159
|
+
/>
|
|
160
|
+
<button type="submit" className="cv-p-button cv-p-button--primary" disabled={!canSend}>
|
|
161
|
+
<span aria-hidden="true">➤</span>
|
|
162
|
+
<span className="cv-p-sr-only">{labels.send}</span>
|
|
163
|
+
</button>
|
|
164
|
+
</div>
|
|
165
|
+
</form>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { ParticipantConversations } from './ParticipantConversations'
|
|
5
|
+
import { ParticipantConversationsScreen, type ParticipantConversationsScreenProps } from './ParticipantConversationsScreen'
|
|
6
|
+
import type { ParticipantConversationsApi } from './participantApi.types'
|
|
7
|
+
import { buildConversation, findForeignClassTokens, findUtilityClassTokens } from './participantFixtures.test-helper'
|
|
8
|
+
|
|
9
|
+
const API: ParticipantConversationsApi = {
|
|
10
|
+
listConversations: async () => ({ data: [] }),
|
|
11
|
+
fetchMessages: async () => [],
|
|
12
|
+
sendMessage: async () => ({ outcome: 'queued' }),
|
|
13
|
+
markRead: async () => undefined,
|
|
14
|
+
resolveAttachmentUrl: async () => 'https://files.example/a',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const GROUPS = [{ subjectType: 'invoice', label: 'Invoices' }]
|
|
18
|
+
|
|
19
|
+
function renderScreen(overrides: Partial<ParticipantConversationsScreenProps> = {}): string {
|
|
20
|
+
return renderToStaticMarkup(
|
|
21
|
+
<ParticipantConversationsScreen
|
|
22
|
+
api={API}
|
|
23
|
+
subjectGroups={GROUPS}
|
|
24
|
+
selected={undefined}
|
|
25
|
+
onSelect={() => undefined}
|
|
26
|
+
inbox={{
|
|
27
|
+
status: 'ready',
|
|
28
|
+
conversations: [buildConversation({ subjectId: '1', subjectLabel: 'Invoice 4521' })],
|
|
29
|
+
hasMore: false,
|
|
30
|
+
refresh: async () => undefined,
|
|
31
|
+
loadMore: async () => undefined,
|
|
32
|
+
markSubjectRead: () => undefined,
|
|
33
|
+
}}
|
|
34
|
+
{...overrides}
|
|
35
|
+
/>,
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('ParticipantConversations', () => {
|
|
40
|
+
it('renders the list without any ConversationsProvider around it', () => {
|
|
41
|
+
const markup = renderToStaticMarkup(
|
|
42
|
+
<ParticipantConversations api={API} subjectGroups={GROUPS} selected={undefined} onSelect={() => undefined} />,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
expect(markup).toContain('cv-p-inbox')
|
|
46
|
+
expect(findUtilityClassTokens(markup)).toEqual([])
|
|
47
|
+
expect(findForeignClassTokens(markup)).toEqual([])
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('renders the inbox rows when selected is undefined', () => {
|
|
51
|
+
const markup = renderScreen()
|
|
52
|
+
|
|
53
|
+
expect(markup).toContain('cv-p-inbox')
|
|
54
|
+
expect(markup).toContain('Invoice 4521')
|
|
55
|
+
expect(markup).not.toContain('cv-p-thread')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('mounts the conversation shell with the subject label when selected is set', () => {
|
|
59
|
+
const markup = renderScreen({ selected: { subjectType: 'invoice', subjectId: '1' } })
|
|
60
|
+
|
|
61
|
+
expect(markup).toContain('cv-p-thread__title')
|
|
62
|
+
expect(markup).toContain('Invoice 4521')
|
|
63
|
+
expect(markup).not.toContain('cv-p-inbox')
|
|
64
|
+
expect(findUtilityClassTokens(markup)).toEqual([])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('shows the not-found state when the subject is absent and the api cannot open it', () => {
|
|
68
|
+
const markup = renderScreen({ selected: { subjectType: 'invoice', subjectId: '999' } })
|
|
69
|
+
|
|
70
|
+
expect(markup).toContain('Conversation not found')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('merges partial labels over the defaults', () => {
|
|
74
|
+
const markup = renderScreen({ labels: { inboxTitle: 'Minhas conversas' } })
|
|
75
|
+
|
|
76
|
+
expect(markup).toContain('Minhas conversas')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('applies the theme as --cv-p-* variables and the class names on the wrapper', () => {
|
|
80
|
+
const markup = renderScreen({
|
|
81
|
+
theme: { primaryColor: '#123456' },
|
|
82
|
+
className: 'host-wrapper',
|
|
83
|
+
classNames: { inbox: 'host-inbox' },
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
expect(markup).toContain('--cv-p-accent:#123456')
|
|
87
|
+
expect(markup).toContain('host-wrapper')
|
|
88
|
+
expect(markup).toContain('host-inbox')
|
|
89
|
+
})
|
|
90
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
4
|
+
|
|
5
|
+
import type { ConversationChannel } from '../conversationChannel'
|
|
6
|
+
import type { QuickReply } from '../quickReplies/quickReply.types'
|
|
7
|
+
import type { ConversationsTheme } from '../types'
|
|
8
|
+
import { ParticipantConversationsScreen } from './ParticipantConversationsScreen'
|
|
9
|
+
import type {
|
|
10
|
+
ParticipantConversationsClassNames,
|
|
11
|
+
ParticipantSubjectGroup,
|
|
12
|
+
ParticipantSubjectIconRenderer,
|
|
13
|
+
} from './participant.types'
|
|
14
|
+
import type { ParticipantConversationsApi, ParticipantPendingMessage } from './participantApi.types'
|
|
15
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
16
|
+
import { useParticipantInbox } from './useParticipantInbox'
|
|
17
|
+
|
|
18
|
+
export type ParticipantConversationsProps = {
|
|
19
|
+
api: ParticipantConversationsApi
|
|
20
|
+
subjectGroups: readonly ParticipantSubjectGroup[]
|
|
21
|
+
/** undefined renders the list; defined renders that conversation. */
|
|
22
|
+
selected: ParticipantSubjectRef | undefined
|
|
23
|
+
onSelect: (subject: ParticipantSubjectRef) => void
|
|
24
|
+
onBack?: () => void
|
|
25
|
+
channel?: ConversationChannel
|
|
26
|
+
labels?: Partial<ParticipantConversationsLabels>
|
|
27
|
+
locale?: string
|
|
28
|
+
theme?: ConversationsTheme
|
|
29
|
+
className?: string
|
|
30
|
+
classNames?: Partial<ParticipantConversationsClassNames>
|
|
31
|
+
pendingMessages?: readonly ParticipantPendingMessage[]
|
|
32
|
+
onRetryPending?: (clientMessageId: string) => void
|
|
33
|
+
quickReplies?: readonly QuickReply[]
|
|
34
|
+
renderSubjectIcon?: ParticipantSubjectIconRenderer
|
|
35
|
+
renderSubjectCard?: (conversation: ParticipantConversationSummary) => ReactNode
|
|
36
|
+
onOpenSubject?: (subject: ParticipantSubjectRef) => void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ParticipantConversations(props: ParticipantConversationsProps) {
|
|
40
|
+
const inbox = useParticipantInbox(props.api)
|
|
41
|
+
const { status, conversations, hasMore, refresh, loadMore, markSubjectRead } = inbox
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<ParticipantConversationsScreen
|
|
45
|
+
{...props}
|
|
46
|
+
inbox={{ status, conversations, hasMore, refresh, loadMore, markSubjectRead }}
|
|
47
|
+
/>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useMemo, useReducer, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { ParticipantInbox } from './ParticipantInbox'
|
|
4
|
+
import type { ParticipantConversationsProps } from './ParticipantConversations'
|
|
5
|
+
import { ParticipantThreadScreen } from './ParticipantThreadScreen'
|
|
6
|
+
import { draftKey, type ParticipantDrafts } from './participantDrafts'
|
|
7
|
+
import { groupParticipantConversations } from './participantGrouping'
|
|
8
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
9
|
+
import { isInboxSearchVisible } from './participantProtocol'
|
|
10
|
+
import { participantSendStatesReducer } from './participantSendStates'
|
|
11
|
+
import { buildParticipantThemeStyle } from './participantTheme'
|
|
12
|
+
import type { UseParticipantInboxResult } from './useParticipantInbox'
|
|
13
|
+
|
|
14
|
+
export type ParticipantConversationsScreenProps = ParticipantConversationsProps & {
|
|
15
|
+
readonly inbox: Pick<
|
|
16
|
+
UseParticipantInboxResult,
|
|
17
|
+
'status' | 'conversations' | 'hasMore' | 'refresh' | 'loadMore' | 'markSubjectRead'
|
|
18
|
+
>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ALL_FILTER = 'all'
|
|
22
|
+
|
|
23
|
+
export function ParticipantConversationsScreen(props: ParticipantConversationsScreenProps) {
|
|
24
|
+
const { selected, inbox, subjectGroups, classNames } = props
|
|
25
|
+
const [filter, setFilter] = useState(ALL_FILTER)
|
|
26
|
+
const [searchQuery, setSearchQuery] = useState('')
|
|
27
|
+
const draftsRef = useRef<ParticipantDrafts>(new Map())
|
|
28
|
+
const [sendStates, dispatchSendStates] = useReducer(participantSendStatesReducer, new Map())
|
|
29
|
+
const labels = useMemo(() => ({ ...DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, ...props.labels }), [props.labels])
|
|
30
|
+
const view = useMemo(
|
|
31
|
+
() =>
|
|
32
|
+
groupParticipantConversations({
|
|
33
|
+
conversations: inbox.conversations,
|
|
34
|
+
subjectGroups,
|
|
35
|
+
filter,
|
|
36
|
+
searchQuery,
|
|
37
|
+
}),
|
|
38
|
+
[inbox.conversations, subjectGroups, filter, searchQuery],
|
|
39
|
+
)
|
|
40
|
+
const isSearchVisible = isInboxSearchVisible({ conversations: inbox.conversations, searchQuery })
|
|
41
|
+
const rootClassName = ['cv-p-root', props.className, classNames?.root].filter(Boolean).join(' ')
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className={rootClassName} style={buildParticipantThemeStyle(props.theme)}>
|
|
45
|
+
{selected === undefined ? (
|
|
46
|
+
<div className={['cv-p-root__screen', classNames?.inbox].filter(Boolean).join(' ')}>
|
|
47
|
+
<ParticipantInbox
|
|
48
|
+
view={view}
|
|
49
|
+
subjectGroups={subjectGroups}
|
|
50
|
+
filter={filter}
|
|
51
|
+
onFilterChange={setFilter}
|
|
52
|
+
onSelect={props.onSelect}
|
|
53
|
+
labels={labels}
|
|
54
|
+
status={inbox.status}
|
|
55
|
+
refresh={() => void inbox.refresh()}
|
|
56
|
+
hasMore={inbox.hasMore}
|
|
57
|
+
loadMore={() => void inbox.loadMore()}
|
|
58
|
+
locale={props.locale}
|
|
59
|
+
renderSubjectIcon={props.renderSubjectIcon}
|
|
60
|
+
search={{ value: searchQuery, onChange: setSearchQuery, isVisible: isSearchVisible }}
|
|
61
|
+
/>
|
|
62
|
+
</div>
|
|
63
|
+
) : (
|
|
64
|
+
<div className={['cv-p-root__screen', classNames?.thread].filter(Boolean).join(' ')}>
|
|
65
|
+
<ParticipantThreadScreen
|
|
66
|
+
key={draftKey(selected)}
|
|
67
|
+
{...props}
|
|
68
|
+
selected={selected}
|
|
69
|
+
labels={labels}
|
|
70
|
+
draftsRef={draftsRef}
|
|
71
|
+
sendStates={sendStates}
|
|
72
|
+
dispatchSendStates={dispatchSendStates}
|
|
73
|
+
/>
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
|
|
4
|
+
import { buildConversation, findForeignClassTokens, findUtilityClassTokens } from './participantFixtures.test-helper'
|
|
5
|
+
import { groupParticipantConversations } from './participantGrouping'
|
|
6
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
7
|
+
import type { ParticipantSubjectGroup } from './participant.types'
|
|
8
|
+
import { ParticipantInbox, type ParticipantInboxProps } from './ParticipantInbox'
|
|
9
|
+
|
|
10
|
+
const GROUPS: readonly ParticipantSubjectGroup[] = [
|
|
11
|
+
{ subjectType: 'invoice', label: 'Invoice' },
|
|
12
|
+
{ subjectType: 'incident', label: 'Incident' },
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
const CONVERSATIONS = [
|
|
16
|
+
buildConversation({
|
|
17
|
+
subjectId: '1',
|
|
18
|
+
subjectLabel: 'Awaiting one',
|
|
19
|
+
awaitingParticipant: true,
|
|
20
|
+
unreadCount: 2,
|
|
21
|
+
lastMessagePreview: 'Please send the photo',
|
|
22
|
+
}),
|
|
23
|
+
buildConversation({ subjectId: '2', subjectLabel: 'Invoice two' }),
|
|
24
|
+
buildConversation({ subjectId: '3', subjectType: 'incident', subjectLabel: 'Incident three' }),
|
|
25
|
+
buildConversation({ subjectId: '4', subjectLabel: 'Old one', status: 'closed' }),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
type RenderOverrides = {
|
|
29
|
+
conversations?: typeof CONVERSATIONS
|
|
30
|
+
filter?: string
|
|
31
|
+
status?: 'idle' | 'loading' | 'ready' | 'error'
|
|
32
|
+
hasMore?: boolean
|
|
33
|
+
renderSubjectIcon?: ParticipantInboxProps['renderSubjectIcon']
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function render(overrides: RenderOverrides = {}): string {
|
|
37
|
+
const view = groupParticipantConversations({
|
|
38
|
+
conversations: overrides.conversations ?? CONVERSATIONS,
|
|
39
|
+
subjectGroups: GROUPS,
|
|
40
|
+
filter: overrides.filter,
|
|
41
|
+
})
|
|
42
|
+
return renderToStaticMarkup(
|
|
43
|
+
<ParticipantInbox
|
|
44
|
+
view={view}
|
|
45
|
+
subjectGroups={GROUPS}
|
|
46
|
+
filter={overrides.filter ?? 'all'}
|
|
47
|
+
onFilterChange={() => undefined}
|
|
48
|
+
onSelect={() => undefined}
|
|
49
|
+
labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS}
|
|
50
|
+
status={overrides.status ?? 'ready'}
|
|
51
|
+
refresh={() => undefined}
|
|
52
|
+
hasMore={overrides.hasMore ?? false}
|
|
53
|
+
loadMore={() => undefined}
|
|
54
|
+
renderSubjectIcon={overrides.renderSubjectIcon}
|
|
55
|
+
/>,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
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
|
+
|
|
148
|
+
describe('ParticipantInbox', () => {
|
|
149
|
+
it('renders section headers with label and count, awaiting highlighted', () => {
|
|
150
|
+
const markup = render()
|
|
151
|
+
|
|
152
|
+
expect(markup).toContain('Waiting for your reply')
|
|
153
|
+
expect(markup).toContain('cv-p-section--awaiting')
|
|
154
|
+
expect(markup).toContain('Invoice')
|
|
155
|
+
expect(markup).toContain('Incident')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('renders rows as buttons with the subject label as text and the preview', () => {
|
|
159
|
+
const markup = render()
|
|
160
|
+
|
|
161
|
+
expect(markup).toContain('<button')
|
|
162
|
+
expect(markup).toContain('Awaiting one')
|
|
163
|
+
expect(markup).toContain('Please send the photo')
|
|
164
|
+
expect(markup).toContain('cv-p-row__kind')
|
|
165
|
+
expect(markup).toContain('cv-p-row--awaiting')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('shows an unread badge with accessible text only when there are unread messages', () => {
|
|
169
|
+
const markup = render()
|
|
170
|
+
|
|
171
|
+
expect(markup).toContain('2 unread')
|
|
172
|
+
expect(markup.match(/cv-p-row__unread/g)?.length).toBe(1)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('puts closed conversations inside a collapsible details', () => {
|
|
176
|
+
const markup = render()
|
|
177
|
+
|
|
178
|
+
expect(markup).toContain('<details')
|
|
179
|
+
expect(markup).toContain('Closed')
|
|
180
|
+
expect(markup).toContain('Old one')
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('renders filters only when showFilters is true', () => {
|
|
184
|
+
const withFilters = render()
|
|
185
|
+
const single = render({ conversations: [CONVERSATIONS[1] as (typeof CONVERSATIONS)[number]] })
|
|
186
|
+
|
|
187
|
+
expect(withFilters).toContain('cv-p-filters')
|
|
188
|
+
expect(withFilters).toContain('aria-pressed="true"')
|
|
189
|
+
expect(single).not.toContain('cv-p-filters')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('renders the empty state when there are no sections', () => {
|
|
193
|
+
const markup = render({ conversations: [] })
|
|
194
|
+
|
|
195
|
+
expect(markup).toContain('No conversations yet')
|
|
196
|
+
expect(markup).not.toContain('cv-p-row"')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('while loading shows a busy skeleton and never the empty state', () => {
|
|
200
|
+
const markup = render({ conversations: [], status: 'loading' })
|
|
201
|
+
|
|
202
|
+
expect(markup).toContain('aria-busy="true"')
|
|
203
|
+
expect(markup).toContain('Loading…')
|
|
204
|
+
expect(markup).not.toContain('No conversations yet')
|
|
205
|
+
expect(render({ conversations: [], status: 'idle' })).not.toContain('No conversations yet')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('on error shows an alert with retry and never the empty state', () => {
|
|
209
|
+
const markup = render({ conversations: [], status: 'error' })
|
|
210
|
+
|
|
211
|
+
expect(markup).toContain('role="alert"')
|
|
212
|
+
expect(markup).toContain('Could not load')
|
|
213
|
+
expect(markup).toContain('Retry')
|
|
214
|
+
expect(markup).not.toContain('No conversations yet')
|
|
215
|
+
expect(markup).not.toContain('aria-busy="true"')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('on success shows the empty state without busy or alert', () => {
|
|
219
|
+
const markup = render({ conversations: [], status: 'ready' })
|
|
220
|
+
|
|
221
|
+
expect(markup).toContain('No conversations yet')
|
|
222
|
+
expect(markup).not.toContain('role="alert"')
|
|
223
|
+
expect(markup).toContain('aria-busy="false"')
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('keeps the rows and adds the alert when a later request fails', () => {
|
|
227
|
+
const markup = render({ status: 'error' })
|
|
228
|
+
|
|
229
|
+
expect(markup).toContain('Awaiting one')
|
|
230
|
+
expect(markup).toContain('role="alert"')
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('shows load more only when there is a next page', () => {
|
|
234
|
+
expect(render()).not.toContain('Load more')
|
|
235
|
+
expect(render({ hasMore: true })).toContain('Load more')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('uses only cv-p-* classes and no Tailwind utilities', () => {
|
|
239
|
+
const markup = render()
|
|
240
|
+
|
|
241
|
+
expect(findUtilityClassTokens(markup)).toEqual([])
|
|
242
|
+
expect(findForeignClassTokens(markup)).toEqual([])
|
|
243
|
+
})
|
|
244
|
+
})
|