@adatechnology/conversations-ui 0.5.0 → 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 +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 +3 -3
- 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 +98 -0
- package/package.json +2 -2
- 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/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/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/styles.css +19 -1
- package/dist/chunk-WSFGNR5R.js +0 -55
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
2
2
|
|
|
3
|
-
import { formatTimestamp, isSameDay } from '../lib/format'
|
|
4
3
|
import type {
|
|
5
4
|
ParticipantInboxFilter,
|
|
6
5
|
ParticipantInboxSection,
|
|
7
6
|
ParticipantInboxView,
|
|
8
7
|
ParticipantSubjectGroup,
|
|
8
|
+
ParticipantSubjectIconRenderer,
|
|
9
9
|
} from './participant.types'
|
|
10
|
-
import {
|
|
10
|
+
import { type ParticipantConversationsLabels } from './participantLabels'
|
|
11
11
|
import { resolveLoadView, type ParticipantLoadStatus } from './participantLoadView'
|
|
12
|
+
import { ParticipantInboxRow } from './ParticipantInboxRow'
|
|
13
|
+
import { ParticipantInboxSearchField, type ParticipantInboxSearch } from './ParticipantInboxSearchField'
|
|
12
14
|
import { ParticipantLoadError, ParticipantLoading } from './ParticipantLoadState'
|
|
13
15
|
|
|
14
16
|
export type ParticipantInboxProps = {
|
|
@@ -24,14 +26,17 @@ export type ParticipantInboxProps = {
|
|
|
24
26
|
readonly hasMore: boolean
|
|
25
27
|
readonly loadMore: () => void
|
|
26
28
|
readonly locale?: string
|
|
29
|
+
/** Absent = no search field. The host narrows the view with the query; the field only edits it. */
|
|
30
|
+
readonly search?: ParticipantInboxSearch
|
|
31
|
+
/** Absent, or returning null/undefined, = the subject group icon. */
|
|
32
|
+
readonly renderSubjectIcon?: ParticipantSubjectIconRenderer
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
const ALL_FILTER = 'all'
|
|
30
36
|
|
|
31
|
-
function
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return date.toLocaleDateString(locale, { day: '2-digit', month: '2-digit' })
|
|
37
|
+
function resolveNoResultsLabel({ view, hasMore, labels }: ParticipantInboxProps): string {
|
|
38
|
+
if (view.hasMatchesOutsideFilter) return labels.noResultsInFilter
|
|
39
|
+
return hasMore ? labels.noResultsLoadedOnly : labels.noResults
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
function sectionTitle(section: ParticipantInboxSection, props: ParticipantInboxProps): string {
|
|
@@ -67,42 +72,6 @@ function Filters({ filters, active, labels, onChange }: FiltersProps) {
|
|
|
67
72
|
)
|
|
68
73
|
}
|
|
69
74
|
|
|
70
|
-
type RowProps = {
|
|
71
|
-
readonly conversation: ParticipantConversationSummary
|
|
72
|
-
readonly group: ParticipantSubjectGroup | undefined
|
|
73
|
-
readonly labels: ParticipantConversationsLabels
|
|
74
|
-
readonly locale?: string
|
|
75
|
-
readonly onSelect: (subject: ParticipantSubjectRef) => void
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function Row({ conversation, group, labels, locale, onSelect }: RowProps) {
|
|
79
|
-
const kind = group?.label ?? conversation.subjectType
|
|
80
|
-
const className = conversation.awaitingParticipant ? 'cv-p-row cv-p-row--awaiting' : 'cv-p-row'
|
|
81
|
-
const { subjectType, subjectId } = conversation
|
|
82
|
-
|
|
83
|
-
return (
|
|
84
|
-
<button type="button" className={className} onClick={() => onSelect({ subjectType, subjectId })}>
|
|
85
|
-
<span className="cv-p-row__icon" aria-hidden="true">
|
|
86
|
-
{group?.icon ?? kind.slice(0, 2)}
|
|
87
|
-
</span>
|
|
88
|
-
<span className="cv-p-row__body">
|
|
89
|
-
<span className="cv-p-row__kind">{kind}</span>
|
|
90
|
-
<span className="cv-p-row__title">{conversation.subjectLabel}</span>
|
|
91
|
-
{conversation.lastMessagePreview ? <span className="cv-p-row__preview">{conversation.lastMessagePreview}</span> : null}
|
|
92
|
-
</span>
|
|
93
|
-
<span className="cv-p-row__meta">
|
|
94
|
-
{conversation.lastMessageAt ? <span>{formatRowTime(conversation.lastMessageAt, locale)}</span> : null}
|
|
95
|
-
{conversation.unreadCount > 0 ? (
|
|
96
|
-
<span className="cv-p-row__unread">
|
|
97
|
-
<span aria-hidden="true">{conversation.unreadCount}</span>
|
|
98
|
-
<span className="cv-p-sr-only">{formatParticipantLabel(labels.unreadCount, conversation.unreadCount)}</span>
|
|
99
|
-
</span>
|
|
100
|
-
) : null}
|
|
101
|
-
</span>
|
|
102
|
-
</button>
|
|
103
|
-
)
|
|
104
|
-
}
|
|
105
|
-
|
|
106
75
|
function SectionHeading({ title, count }: { readonly title: string; readonly count: number }) {
|
|
107
76
|
return (
|
|
108
77
|
<>
|
|
@@ -112,15 +81,22 @@ function SectionHeading({ title, count }: { readonly title: string; readonly cou
|
|
|
112
81
|
)
|
|
113
82
|
}
|
|
114
83
|
|
|
115
|
-
function Section({
|
|
84
|
+
function Section({
|
|
85
|
+
section,
|
|
86
|
+
props,
|
|
87
|
+
}: {
|
|
88
|
+
readonly section: ParticipantInboxSection
|
|
89
|
+
readonly props: ParticipantInboxProps
|
|
90
|
+
}) {
|
|
116
91
|
const rows = section.conversations.map((conversation) => (
|
|
117
|
-
<
|
|
92
|
+
<ParticipantInboxRow
|
|
118
93
|
key={`${conversation.subjectType}:${conversation.subjectId}`}
|
|
119
94
|
conversation={conversation}
|
|
120
95
|
group={props.subjectGroups.find((group) => group.subjectType === conversation.subjectType)}
|
|
121
96
|
labels={props.labels}
|
|
122
97
|
locale={props.locale}
|
|
123
98
|
onSelect={props.onSelect}
|
|
99
|
+
renderSubjectIcon={props.renderSubjectIcon}
|
|
124
100
|
/>
|
|
125
101
|
))
|
|
126
102
|
const heading = <SectionHeading title={sectionTitle(section, props)} count={section.conversations.length} />
|
|
@@ -144,17 +120,22 @@ function Section({ section, props }: { readonly section: ParticipantInboxSection
|
|
|
144
120
|
|
|
145
121
|
export function ParticipantInbox(props: ParticipantInboxProps) {
|
|
146
122
|
const { view, labels, filter, onFilterChange } = props
|
|
147
|
-
const
|
|
123
|
+
const isSearching = (props.search?.value.trim() ?? '') !== ''
|
|
124
|
+
const loadView = resolveLoadView({ status: props.status, hasItems: view.sections.length > 0 || isSearching })
|
|
125
|
+
const hasNoResults = isSearching && view.sections.length === 0 && props.status === 'ready'
|
|
126
|
+
const noResultsLabel = resolveNoResultsLabel(props)
|
|
148
127
|
|
|
149
128
|
return (
|
|
150
129
|
<div className="cv-p cv-p-inbox" aria-busy={loadView.isLoading}>
|
|
151
130
|
<h2 className="cv-p-inbox__title">{labels.inboxTitle}</h2>
|
|
152
131
|
{view.showFilters ? (
|
|
153
|
-
<Filters filters={view.filters} active={
|
|
132
|
+
<Filters filters={view.filters} active={view.activeFilter} labels={labels} onChange={onFilterChange} />
|
|
154
133
|
) : null}
|
|
134
|
+
{props.search?.isVisible ? <ParticipantInboxSearchField search={props.search} labels={labels} /> : null}
|
|
155
135
|
{loadView.isLoading ? <ParticipantLoading labels={labels} /> : null}
|
|
156
136
|
{loadView.hasError ? <ParticipantLoadError labels={labels} onRetry={props.refresh} /> : null}
|
|
157
137
|
{loadView.isEmpty ? <p className="cv-p-empty">{labels.emptyInbox}</p> : null}
|
|
138
|
+
{hasNoResults ? <p className="cv-p-empty">{noResultsLabel}</p> : null}
|
|
158
139
|
{view.sections.map((section) => (
|
|
159
140
|
<Section key={section.key} section={section} props={props} />
|
|
160
141
|
))}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
2
|
+
|
|
3
|
+
import { formatTimestamp, isSameDay } from '../lib/format'
|
|
4
|
+
import type { ParticipantSubjectGroup, ParticipantSubjectIconRenderer } from './participant.types'
|
|
5
|
+
import { formatParticipantLabel, type ParticipantConversationsLabels } from './participantLabels'
|
|
6
|
+
import { ParticipantChannelBadges } from './ParticipantChannelBadges'
|
|
7
|
+
|
|
8
|
+
export type ParticipantInboxRowProps = {
|
|
9
|
+
readonly conversation: ParticipantConversationSummary
|
|
10
|
+
readonly group: ParticipantSubjectGroup | undefined
|
|
11
|
+
readonly labels: ParticipantConversationsLabels
|
|
12
|
+
readonly locale?: string
|
|
13
|
+
readonly onSelect: (subject: ParticipantSubjectRef) => void
|
|
14
|
+
readonly renderSubjectIcon?: ParticipantSubjectIconRenderer
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function formatRowTime(iso: string, locale: string | undefined): string {
|
|
18
|
+
const date = new Date(iso)
|
|
19
|
+
if (isSameDay(date, new Date())) return formatTimestamp(iso)
|
|
20
|
+
return date.toLocaleDateString(locale, { day: '2-digit', month: '2-digit' })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function RowIcon({ props, kind }: { readonly props: ParticipantInboxRowProps; readonly kind: string }) {
|
|
24
|
+
const custom = props.renderSubjectIcon?.(props.conversation)
|
|
25
|
+
if (custom !== null && custom !== undefined && custom !== false && custom !== '') {
|
|
26
|
+
return (
|
|
27
|
+
<span className="cv-p-row__icon cv-p-inbox__icon" aria-hidden="true">
|
|
28
|
+
{custom}
|
|
29
|
+
</span>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
return (
|
|
33
|
+
<span className="cv-p-row__icon" aria-hidden="true">
|
|
34
|
+
{props.group?.icon ?? kind.slice(0, 2)}
|
|
35
|
+
</span>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ParticipantInboxRow(props: ParticipantInboxRowProps) {
|
|
40
|
+
const { conversation, group, labels, locale, onSelect } = props
|
|
41
|
+
const kind = group?.label ?? conversation.subjectType
|
|
42
|
+
const className = conversation.awaitingParticipant ? 'cv-p-row cv-p-row--awaiting' : 'cv-p-row'
|
|
43
|
+
const { subjectType, subjectId } = conversation
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<button type="button" className={className} onClick={() => onSelect({ subjectType, subjectId })}>
|
|
47
|
+
<RowIcon props={props} kind={kind} />
|
|
48
|
+
<span className="cv-p-row__body">
|
|
49
|
+
<span className="cv-p-row__kind">{kind}</span>
|
|
50
|
+
<span className="cv-p-row__title">{conversation.subjectLabel}</span>
|
|
51
|
+
<span className="cv-p-row__tags">
|
|
52
|
+
{conversation.protocol ? (
|
|
53
|
+
<span className="cv-p-protocol">
|
|
54
|
+
<span className="cv-p-sr-only">{labels.protocolPrefix} </span>
|
|
55
|
+
{conversation.protocol}
|
|
56
|
+
</span>
|
|
57
|
+
) : null}
|
|
58
|
+
<ParticipantChannelBadges channels={conversation.channels} labels={labels} variant="inline" />
|
|
59
|
+
</span>
|
|
60
|
+
{conversation.lastMessagePreview ? (
|
|
61
|
+
<span className="cv-p-row__preview">{conversation.lastMessagePreview}</span>
|
|
62
|
+
) : null}
|
|
63
|
+
</span>
|
|
64
|
+
<span className="cv-p-row__meta">
|
|
65
|
+
{conversation.lastMessageAt ? <span>{formatRowTime(conversation.lastMessageAt, locale)}</span> : null}
|
|
66
|
+
{conversation.unreadCount > 0 ? (
|
|
67
|
+
<span className="cv-p-row__unread">
|
|
68
|
+
<span aria-hidden="true">{conversation.unreadCount}</span>
|
|
69
|
+
<span className="cv-p-sr-only">{formatParticipantLabel(labels.unreadCount, conversation.unreadCount)}</span>
|
|
70
|
+
</span>
|
|
71
|
+
) : null}
|
|
72
|
+
</span>
|
|
73
|
+
</button>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useId } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
4
|
+
|
|
5
|
+
export type ParticipantInboxSearch = {
|
|
6
|
+
readonly value: string
|
|
7
|
+
readonly onChange: (value: string) => void
|
|
8
|
+
readonly isVisible: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type ParticipantInboxSearchFieldProps = {
|
|
12
|
+
readonly search: ParticipantInboxSearch
|
|
13
|
+
readonly labels: ParticipantConversationsLabels
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function ParticipantInboxSearchField({ search, labels }: ParticipantInboxSearchFieldProps) {
|
|
17
|
+
const inputId = useId()
|
|
18
|
+
return (
|
|
19
|
+
<div className="cv-p-search">
|
|
20
|
+
<label className="cv-p-sr-only" htmlFor={inputId}>
|
|
21
|
+
{labels.searchLabel}
|
|
22
|
+
</label>
|
|
23
|
+
<input
|
|
24
|
+
id={inputId}
|
|
25
|
+
type="search"
|
|
26
|
+
className="cv-p-search__input"
|
|
27
|
+
value={search.value}
|
|
28
|
+
placeholder={labels.searchPlaceholder}
|
|
29
|
+
autoComplete="off"
|
|
30
|
+
onChange={(event) => search.onChange(event.target.value)}
|
|
31
|
+
/>
|
|
32
|
+
</div>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -105,6 +105,8 @@ describe('ParticipantMessageBubble', () => {
|
|
|
105
105
|
|
|
106
106
|
expect(withRetry).toContain('<button')
|
|
107
107
|
expect(withRetry).toContain('Failed — tap to retry')
|
|
108
|
+
expect(withRetry).toContain('cv-status-ticks--failed')
|
|
109
|
+
expect(findUtilityClassTokens(withRetry)).toEqual([])
|
|
108
110
|
expect(withoutRetry).not.toContain('<button')
|
|
109
111
|
expect(withoutRetry).toContain('cv-status-ticks--failed')
|
|
110
112
|
})
|
|
@@ -85,7 +85,7 @@ function OwnStatus({ status, labels, onRetry }: OwnStatusProps) {
|
|
|
85
85
|
if (status === 'failed' && onRetry) {
|
|
86
86
|
return (
|
|
87
87
|
<button type="button" className="cv-p-bubble__retry" onClick={onRetry}>
|
|
88
|
-
<StatusTicks status="failed" />
|
|
88
|
+
<StatusTicks status="failed" appearance="stylesheet" />
|
|
89
89
|
<span>{labels.statusFailed}</span>
|
|
90
90
|
</button>
|
|
91
91
|
)
|
|
@@ -94,7 +94,7 @@ function OwnStatus({ status, labels, onRetry }: OwnStatusProps) {
|
|
|
94
94
|
const text = status === 'failed' ? labels.statusFailedShort : statusText(status, labels)
|
|
95
95
|
return (
|
|
96
96
|
<span className="cv-p-bubble__status">
|
|
97
|
-
<StatusTicks status={ticksStatus} />
|
|
97
|
+
<StatusTicks status={ticksStatus} appearance="stylesheet" />
|
|
98
98
|
<span className={isVisibleText ? 'cv-p-bubble__status-text' : 'cv-p-sr-only'}>{text}</span>
|
|
99
99
|
</span>
|
|
100
100
|
)
|
|
@@ -158,7 +158,7 @@ export function ParticipantMessageBubble({
|
|
|
158
158
|
<span className="cv-p-bubble__author">{content.authorName}</span>
|
|
159
159
|
) : null}
|
|
160
160
|
{content.text ? (
|
|
161
|
-
<MessageText message={toTextPayload(content.text, content.createdAt, isMine)} copyOnClick={false} />
|
|
161
|
+
<MessageText message={toTextPayload(content.text, content.createdAt, isMine)} copyOnClick={false} appearance="stylesheet" />
|
|
162
162
|
) : null}
|
|
163
163
|
{content.attachments.map((attachment) => (
|
|
164
164
|
<ParticipantAttachmentItem
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useEffect, useReducer } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
4
|
+
import {
|
|
5
|
+
INITIAL_PROTOCOL_COPY_STATE,
|
|
6
|
+
copyProtocolToClipboard,
|
|
7
|
+
protocolCopyReducer,
|
|
8
|
+
type ProtocolCopyState,
|
|
9
|
+
} from './participantProtocolCopy'
|
|
10
|
+
|
|
11
|
+
const COPIED_ANNOUNCEMENT_MS = 3000
|
|
12
|
+
|
|
13
|
+
export type ParticipantProtocolBadgeProps = {
|
|
14
|
+
readonly protocol: string
|
|
15
|
+
readonly labels: ParticipantConversationsLabels
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ParticipantProtocolBadgeViewProps = ParticipantProtocolBadgeProps & {
|
|
19
|
+
readonly copyState: ProtocolCopyState
|
|
20
|
+
readonly onCopy: () => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ParticipantProtocolBadgeView({
|
|
24
|
+
protocol,
|
|
25
|
+
labels,
|
|
26
|
+
copyState,
|
|
27
|
+
onCopy,
|
|
28
|
+
}: ParticipantProtocolBadgeViewProps) {
|
|
29
|
+
const { isCopied, copyCount } = copyState
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
<div className="cv-p-protocol cv-p-protocol--header">
|
|
33
|
+
<span className="cv-p-protocol__code">
|
|
34
|
+
<span className="cv-p-sr-only">{labels.protocolPrefix} </span>
|
|
35
|
+
{protocol}
|
|
36
|
+
</span>
|
|
37
|
+
<button type="button" className="cv-p-protocol__copy" onClick={onCopy}>
|
|
38
|
+
<span aria-hidden="true">{isCopied ? labels.protocolCopied : labels.copyProtocol}</span>
|
|
39
|
+
<span className="cv-p-sr-only">
|
|
40
|
+
{labels.copyProtocol} {protocol}
|
|
41
|
+
</span>
|
|
42
|
+
</button>
|
|
43
|
+
</div>
|
|
44
|
+
<div className="cv-p-sr-only" role="status" aria-live="polite">
|
|
45
|
+
{isCopied ? <span key={copyCount}>{labels.protocolCopied}</span> : null}
|
|
46
|
+
</div>
|
|
47
|
+
</>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function ParticipantProtocolBadge({ protocol, labels }: ParticipantProtocolBadgeProps) {
|
|
52
|
+
const [copyState, dispatch] = useReducer(protocolCopyReducer, INITIAL_PROTOCOL_COPY_STATE)
|
|
53
|
+
const { isCopied, copyCount } = copyState
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!isCopied) return undefined
|
|
57
|
+
const timer = setTimeout(() => dispatch({ type: 'expired' }), COPIED_ANNOUNCEMENT_MS)
|
|
58
|
+
return () => clearTimeout(timer)
|
|
59
|
+
}, [isCopied, copyCount])
|
|
60
|
+
|
|
61
|
+
async function handleCopy(): Promise<void> {
|
|
62
|
+
const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard
|
|
63
|
+
if (await copyProtocolToClipboard(protocol, clipboard)) dispatch({ type: 'copied' })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<ParticipantProtocolBadgeView
|
|
68
|
+
protocol={protocol}
|
|
69
|
+
labels={labels}
|
|
70
|
+
copyState={copyState}
|
|
71
|
+
onCopy={() => void handleCopy()}
|
|
72
|
+
/>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
5
|
+
|
|
6
|
+
import { buildConversation, findForeignClassTokens, findUtilityClassTokens } from './participantFixtures.test-helper'
|
|
7
|
+
import { groupParticipantConversations } from './participantGrouping'
|
|
8
|
+
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
9
|
+
import { ParticipantInbox } from './ParticipantInbox'
|
|
10
|
+
import { ParticipantProtocolBadgeView } from './ParticipantProtocolBadge'
|
|
11
|
+
import { ParticipantThread } from './ParticipantThread'
|
|
12
|
+
|
|
13
|
+
const LABELS = DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS
|
|
14
|
+
const WITH_PROTOCOL = buildConversation({ subjectId: '1', subjectLabel: 'Item 4521', protocol: '261009-K7M2' })
|
|
15
|
+
const WITHOUT_PROTOCOL = buildConversation({ subjectId: '2', subjectLabel: 'Item 9999' })
|
|
16
|
+
|
|
17
|
+
type InboxOverrides = {
|
|
18
|
+
search?: { value: string; isVisible: boolean }
|
|
19
|
+
hasMore?: boolean
|
|
20
|
+
filter?: string
|
|
21
|
+
hasMatchesOutsideFilter?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function renderInbox(conversations: readonly (typeof WITH_PROTOCOL)[], overrides: InboxOverrides = {}): string {
|
|
25
|
+
const view = {
|
|
26
|
+
...groupParticipantConversations({ conversations, subjectGroups: [], filter: 'all' }),
|
|
27
|
+
...(overrides.hasMatchesOutsideFilter === undefined
|
|
28
|
+
? {}
|
|
29
|
+
: { hasMatchesOutsideFilter: overrides.hasMatchesOutsideFilter }),
|
|
30
|
+
}
|
|
31
|
+
return renderToStaticMarkup(
|
|
32
|
+
<ParticipantInbox
|
|
33
|
+
view={view}
|
|
34
|
+
subjectGroups={[]}
|
|
35
|
+
filter="all"
|
|
36
|
+
onFilterChange={() => undefined}
|
|
37
|
+
onSelect={() => undefined}
|
|
38
|
+
labels={LABELS}
|
|
39
|
+
status="ready"
|
|
40
|
+
refresh={() => undefined}
|
|
41
|
+
hasMore={overrides.hasMore ?? false}
|
|
42
|
+
loadMore={() => undefined}
|
|
43
|
+
search={overrides.search ? { ...overrides.search, onChange: () => undefined } : undefined}
|
|
44
|
+
/>,
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function renderThread(conversation: typeof WITH_PROTOCOL): string {
|
|
49
|
+
return renderToStaticMarkup(
|
|
50
|
+
<ParticipantThread
|
|
51
|
+
conversation={conversation}
|
|
52
|
+
items={[]}
|
|
53
|
+
hasMore={false}
|
|
54
|
+
labels={LABELS}
|
|
55
|
+
resolveAttachmentUrl={async () => 'https://files.example/a'}
|
|
56
|
+
draft={{ value: '', onChange: () => undefined, files: [], onFilesChange: () => undefined }}
|
|
57
|
+
onSend={() => undefined}
|
|
58
|
+
status="ready"
|
|
59
|
+
refresh={() => undefined}
|
|
60
|
+
scroll={{ ref: { current: null }, onScroll: () => undefined }}
|
|
61
|
+
newMessagesCount={0}
|
|
62
|
+
isSending={false}
|
|
63
|
+
/>,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('protocol in the inbox row', () => {
|
|
68
|
+
it('shows the protocol with an accessible name', () => {
|
|
69
|
+
const markup = renderInbox([WITH_PROTOCOL])
|
|
70
|
+
expect(markup).toContain('cv-p-protocol')
|
|
71
|
+
expect(markup).toContain('261009-K7M2')
|
|
72
|
+
expect(markup).toContain('<span class="cv-p-sr-only">Protocol </span>261009-K7M2')
|
|
73
|
+
expect(markup).not.toMatch(/<span[^>]*aria-label/)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('draws nothing when the protocol is absent', () => {
|
|
77
|
+
expect(renderInbox([WITHOUT_PROTOCOL])).not.toContain('cv-p-protocol')
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('inbox search', () => {
|
|
82
|
+
it('is drawn with a label, placeholder and type=search when visible', () => {
|
|
83
|
+
const markup = renderInbox([WITH_PROTOCOL], { search: { value: '', isVisible: true } })
|
|
84
|
+
expect(markup).toContain('type="search"')
|
|
85
|
+
expect(markup).toContain('cv-p-search')
|
|
86
|
+
expect(markup).toContain(LABELS.searchLabel)
|
|
87
|
+
expect(markup).toContain(`placeholder="${LABELS.searchPlaceholder}"`)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('is not drawn when hidden or when the prop is absent', () => {
|
|
91
|
+
expect(renderInbox([WITH_PROTOCOL], { search: { value: '', isVisible: false } })).not.toContain('cv-p-search')
|
|
92
|
+
expect(renderInbox([WITH_PROTOCOL])).not.toContain('cv-p-search')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('shows the no-results state instead of the empty inbox when a query finds nothing', () => {
|
|
96
|
+
const markup = renderInbox([], { search: { value: 'zzz', isVisible: true } })
|
|
97
|
+
expect(markup).toContain(LABELS.noResults)
|
|
98
|
+
expect(markup).not.toContain(LABELS.emptyInbox)
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('protocol in the thread header', () => {
|
|
103
|
+
it('shows the protocol and a copy button of at least a touch target', () => {
|
|
104
|
+
const markup = renderThread(WITH_PROTOCOL)
|
|
105
|
+
expect(markup).toContain('261009-K7M2')
|
|
106
|
+
expect(markup).toContain('cv-p-protocol__copy')
|
|
107
|
+
expect(markup).toContain('type="button"')
|
|
108
|
+
expect(markup).toContain(LABELS.copyProtocol)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('names the code with a visually hidden prefix and no aria-label on the generic span', () => {
|
|
112
|
+
const markup = renderThread(WITH_PROTOCOL)
|
|
113
|
+
expect(markup).toContain(`<span class="cv-p-sr-only">${LABELS.protocolPrefix} </span>261009-K7M2`)
|
|
114
|
+
expect(markup).not.toMatch(/<span[^>]*aria-label/)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('keeps an empty polite live region in the tree', () => {
|
|
118
|
+
const markup = renderThread(WITH_PROTOCOL)
|
|
119
|
+
expect(markup).toMatch(/<div class="cv-p-sr-only" role="status" aria-live="polite"><\/div>/)
|
|
120
|
+
expect(markup).not.toContain(LABELS.protocolCopied)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('draws neither protocol nor button nor live region when absent', () => {
|
|
124
|
+
const markup = renderThread(WITHOUT_PROTOCOL)
|
|
125
|
+
expect(markup).not.toContain('cv-p-protocol')
|
|
126
|
+
expect(markup.match(/aria-live="polite"/g)?.length).toBe(1)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('does not nest a status region inside the protocol block', () => {
|
|
130
|
+
const markup = renderThread(WITH_PROTOCOL)
|
|
131
|
+
const block = markup.slice(
|
|
132
|
+
markup.indexOf('cv-p-protocol--header'),
|
|
133
|
+
markup.indexOf('</button>', markup.indexOf('cv-p-protocol__copy')),
|
|
134
|
+
)
|
|
135
|
+
expect(block).not.toContain('role="status"')
|
|
136
|
+
expect(block).not.toContain('aria-live')
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe('copy button', () => {
|
|
141
|
+
const render = (isCopied: boolean) =>
|
|
142
|
+
renderToStaticMarkup(
|
|
143
|
+
<ParticipantProtocolBadgeView
|
|
144
|
+
protocol="261009-K7M2"
|
|
145
|
+
labels={LABELS}
|
|
146
|
+
copyState={{ copyCount: isCopied ? 1 : 0, isCopied }}
|
|
147
|
+
onCopy={() => undefined}
|
|
148
|
+
/>,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
it('has a default visible label starting the accessible name', () => {
|
|
152
|
+
expect(LABELS.copyProtocol).toBe('Copy protocol')
|
|
153
|
+
const markup = render(false)
|
|
154
|
+
expect(markup).toContain('<span aria-hidden="true">Copy protocol</span>')
|
|
155
|
+
expect(markup).toContain('<span class="cv-p-sr-only">Copy protocol 261009-K7M2</span>')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('swaps the visible text for the confirmation while copied, hidden from assistive tech', () => {
|
|
159
|
+
const markup = render(true)
|
|
160
|
+
expect(markup).toContain(`<span aria-hidden="true">${LABELS.protocolCopied}</span>`)
|
|
161
|
+
expect(markup).toContain(`<span>${LABELS.protocolCopied}</span></div>`)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('announces only through the live region, keyed by the copy count', () => {
|
|
165
|
+
expect(render(false)).toMatch(/aria-live="polite"><\/div>/)
|
|
166
|
+
expect(render(true)).toMatch(/aria-live="polite"><span>Protocol copied<\/span><\/div>/)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe('inbox empty states', () => {
|
|
171
|
+
const search = { value: 'zzz', isVisible: true }
|
|
172
|
+
|
|
173
|
+
it('says the filter is the reason when the query matches in another subject', () => {
|
|
174
|
+
const markup = renderInbox([], { search, hasMatchesOutsideFilter: true })
|
|
175
|
+
expect(markup).toContain(LABELS.noResultsInFilter)
|
|
176
|
+
expect(markup).not.toContain(`>${LABELS.noResults}<`)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('says the search covers only the loaded conversations when more exist', () => {
|
|
180
|
+
const markup = renderInbox([], { search, hasMore: true })
|
|
181
|
+
expect(markup).toContain(LABELS.noResultsLoadedOnly)
|
|
182
|
+
expect(markup).toContain(LABELS.loadMore)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('keeps the plain message when nothing more can be loaded', () => {
|
|
186
|
+
expect(renderInbox([], { search })).toContain(`>${LABELS.noResults}<`)
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('protocol markup hygiene', () => {
|
|
191
|
+
it('uses only cv-p-* classes and no Tailwind utilities', () => {
|
|
192
|
+
const markup =
|
|
193
|
+
renderInbox([WITH_PROTOCOL], { search: { value: '', isVisible: true } }) + renderThread(WITH_PROTOCOL)
|
|
194
|
+
expect(findUtilityClassTokens(markup)).toEqual([])
|
|
195
|
+
expect(findForeignClassTokens(markup)).toEqual([])
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('keeps product vocabulary out of the new sources and labels', () => {
|
|
199
|
+
const files = readdirSync(fileURLToPath(new URL('.', import.meta.url))).filter(
|
|
200
|
+
(file) => /\.tsx?$/.test(file) && !/\.test(-helper)?\.tsx?$/.test(file),
|
|
201
|
+
)
|
|
202
|
+
const source = files.map((file) => readFileSync(new URL(file, import.meta.url), 'utf8')).join('\n')
|
|
203
|
+
expect(files.length).toBeGreaterThan(20)
|
|
204
|
+
expect(source).not.toMatch(
|
|
205
|
+
/\b(carga|entrega|frete|cte|nf-?e|transport\w*|nota|ocorr\w*|motorista|viagem|trip|driver)\b/i,
|
|
206
|
+
)
|
|
207
|
+
})
|
|
208
|
+
})
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it } from 'bun:test'
|
|
2
2
|
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
buildConversation,
|
|
6
|
+
buildMessage,
|
|
7
|
+
findForeignClassTokens,
|
|
8
|
+
findUtilityClassTokens,
|
|
9
|
+
} from './participantFixtures.test-helper'
|
|
5
10
|
import { DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS } from './participantLabels'
|
|
6
11
|
import type { ParticipantTimelineItem } from './participantMessages'
|
|
7
12
|
import { ParticipantThread, type ParticipantThreadProps } from './ParticipantThread'
|
|
@@ -54,6 +59,15 @@ describe('ParticipantThread', () => {
|
|
|
54
59
|
expect(markup).toContain('Card body')
|
|
55
60
|
})
|
|
56
61
|
|
|
62
|
+
it('shows channel badges in the header only with channels', () => {
|
|
63
|
+
expect(render()).not.toContain('cv-p-channels')
|
|
64
|
+
const markup = render({
|
|
65
|
+
conversation: buildConversation({ subjectId: '1', channels: ['email', 'app'] }),
|
|
66
|
+
})
|
|
67
|
+
expect(markup).toContain('cv-p-channel--app')
|
|
68
|
+
expect(markup.indexOf('cv-p-channel--app')).toBeLessThan(markup.indexOf('cv-p-channel--email'))
|
|
69
|
+
})
|
|
70
|
+
|
|
57
71
|
it('renders day dividers between days', () => {
|
|
58
72
|
expect(render().match(/cv-p-day"/g)?.length).toBe(2)
|
|
59
73
|
})
|
|
@@ -92,7 +106,10 @@ describe('ParticipantThread', () => {
|
|
|
92
106
|
})
|
|
93
107
|
|
|
94
108
|
it('disables only the send button while sending, never the field', () => {
|
|
95
|
-
const markup = render({
|
|
109
|
+
const markup = render({
|
|
110
|
+
isSending: true,
|
|
111
|
+
draft: { value: 'hi', onChange: () => undefined, files: [], onFilesChange: () => undefined },
|
|
112
|
+
})
|
|
96
113
|
|
|
97
114
|
expect(markup).toMatch(/<button[^>]*type="submit"[^>]*disabled/)
|
|
98
115
|
expect(markup).not.toMatch(/<textarea[^>]*disabled/)
|
|
@@ -8,13 +8,19 @@ import { isSameDay } from '../lib/format'
|
|
|
8
8
|
import type { QuickReply } from '../quickReplies/quickReply.types'
|
|
9
9
|
import { ParticipantComposer } from './ParticipantComposer'
|
|
10
10
|
import type { ResolveParticipantAttachmentUrl } from './ParticipantAttachmentItem'
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
bindPendingActions,
|
|
13
|
+
resolveParticipantBubbleActions,
|
|
14
|
+
type ParticipantPendingActions,
|
|
15
|
+
} from './participantBubbleActions'
|
|
12
16
|
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
13
17
|
import { resolveLoadView, type ParticipantLoadStatus } from './participantLoadView'
|
|
14
18
|
import { ParticipantLoadError, ParticipantLoading } from './ParticipantLoadState'
|
|
15
19
|
import type { ParticipantThreadScroll } from './useStickToBottom'
|
|
16
20
|
import type { ParticipantTimelineItem } from './participantMessages'
|
|
17
21
|
import { ParticipantMessageBubble } from './ParticipantMessageBubble'
|
|
22
|
+
import { ParticipantChannelBadges } from './ParticipantChannelBadges'
|
|
23
|
+
import { ParticipantProtocolBadge } from './ParticipantProtocolBadge'
|
|
18
24
|
|
|
19
25
|
export type ParticipantThreadDraft = {
|
|
20
26
|
readonly value: string
|
|
@@ -76,20 +82,24 @@ function Header({ props }: { readonly props: ParticipantThreadProps }) {
|
|
|
76
82
|
<span className="cv-p-sr-only">{labels.back}</span>
|
|
77
83
|
</button>
|
|
78
84
|
) : null}
|
|
79
|
-
<
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
85
|
+
<div className="cv-p-thread__heading">
|
|
86
|
+
<h2 className="cv-p-thread__title">
|
|
87
|
+
{onOpenSubject ? (
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
className="cv-p-thread__title-button"
|
|
91
|
+
aria-label={`${labels.openSubject}: ${conversation.subjectLabel}`}
|
|
92
|
+
onClick={() => onOpenSubject({ subjectType, subjectId })}
|
|
93
|
+
>
|
|
94
|
+
{conversation.subjectLabel}
|
|
95
|
+
</button>
|
|
96
|
+
) : (
|
|
97
|
+
conversation.subjectLabel
|
|
98
|
+
)}
|
|
99
|
+
</h2>
|
|
100
|
+
{conversation.protocol ? <ParticipantProtocolBadge protocol={conversation.protocol} labels={labels} /> : null}
|
|
101
|
+
<ParticipantChannelBadges channels={conversation.channels} labels={labels} />
|
|
102
|
+
</div>
|
|
93
103
|
</header>
|
|
94
104
|
)
|
|
95
105
|
}
|