@adatechnology/conversations-ui 0.4.2 → 0.5.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 +169 -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-HMCOTZAX.js → chunk-HOKNKXIT.js} +1 -1
- package/dist/{chunk-4P5KMWOI.js → chunk-JXJCPN34.js} +327 -522
- package/dist/chunk-MRVFFJEH.js +64 -0
- package/dist/chunk-RJMSM3YA.js +249 -0
- package/dist/chunk-WSFGNR5R.js +55 -0
- package/dist/flows/index.js +5 -3
- package/dist/index.d.ts +9 -3
- package/dist/index.js +783 -807
- package/dist/participant/index.d.ts +162 -0
- package/dist/participant/index.js +1448 -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 +591 -0
- package/package.json +8 -4
- package/src/ChannelIcon.tsx +2 -0
- package/src/MessageBubble.test.tsx +2 -2
- package/src/MessageText.test.tsx +59 -0
- package/src/MessageText.tsx +20 -12
- package/src/MessageTextCopiedBadge.tsx +7 -0
- package/src/StatusTicks.test.tsx +30 -0
- package/src/StatusTicks.tsx +24 -9
- 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/participant/ParticipantAttachmentItem.tsx +77 -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 +42 -0
- package/src/participant/ParticipantConversationsScreen.tsx +67 -0
- package/src/participant/ParticipantInbox.test.tsx +147 -0
- package/src/participant/ParticipantInbox.tsx +168 -0
- package/src/participant/ParticipantLoadState.tsx +27 -0
- package/src/participant/ParticipantMessageBubble.test.tsx +165 -0
- package/src/participant/ParticipantMessageBubble.tsx +185 -0
- package/src/participant/ParticipantThread.test.tsx +132 -0
- package/src/participant/ParticipantThread.tsx +174 -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 +25 -0
- package/src/participant/participant.types.ts +43 -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/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 +138 -0
- package/src/participant/participantGrouping.ts +77 -0
- package/src/participant/participantInboxState.test.ts +69 -0
- package/src/participant/participantInboxState.ts +74 -0
- package/src/participant/participantLabels.ts +86 -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/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 +160 -0
- package/src/types.ts +3 -1
|
@@ -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,42 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
ParticipantConversationSummary,
|
|
5
|
+
ParticipantSubjectRef,
|
|
6
|
+
} from '@adatechnology/conversation-contracts'
|
|
7
|
+
|
|
8
|
+
import type { ConversationChannel } from '../conversationChannel'
|
|
9
|
+
import type { QuickReply } from '../quickReplies/quickReply.types'
|
|
10
|
+
import type { ConversationsTheme } from '../types'
|
|
11
|
+
import { ParticipantConversationsScreen } from './ParticipantConversationsScreen'
|
|
12
|
+
import type { ParticipantConversationsClassNames, ParticipantSubjectGroup } from './participant.types'
|
|
13
|
+
import type { ParticipantConversationsApi, ParticipantPendingMessage } from './participantApi.types'
|
|
14
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
15
|
+
import { useParticipantInbox } from './useParticipantInbox'
|
|
16
|
+
|
|
17
|
+
export type ParticipantConversationsProps = {
|
|
18
|
+
api: ParticipantConversationsApi
|
|
19
|
+
subjectGroups: readonly ParticipantSubjectGroup[]
|
|
20
|
+
/** undefined renders the list; defined renders that conversation. */
|
|
21
|
+
selected: ParticipantSubjectRef | undefined
|
|
22
|
+
onSelect: (subject: ParticipantSubjectRef) => void
|
|
23
|
+
onBack?: () => void
|
|
24
|
+
channel?: ConversationChannel
|
|
25
|
+
labels?: Partial<ParticipantConversationsLabels>
|
|
26
|
+
locale?: string
|
|
27
|
+
theme?: ConversationsTheme
|
|
28
|
+
className?: string
|
|
29
|
+
classNames?: Partial<ParticipantConversationsClassNames>
|
|
30
|
+
pendingMessages?: readonly ParticipantPendingMessage[]
|
|
31
|
+
onRetryPending?: (clientMessageId: string) => void
|
|
32
|
+
quickReplies?: readonly QuickReply[]
|
|
33
|
+
renderSubjectCard?: (conversation: ParticipantConversationSummary) => ReactNode
|
|
34
|
+
onOpenSubject?: (subject: ParticipantSubjectRef) => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function ParticipantConversations(props: ParticipantConversationsProps) {
|
|
38
|
+
const inbox = useParticipantInbox(props.api)
|
|
39
|
+
const { status, conversations, hasMore, refresh, loadMore, markSubjectRead } = inbox
|
|
40
|
+
|
|
41
|
+
return <ParticipantConversationsScreen {...props} inbox={{ status, conversations, hasMore, refresh, loadMore, markSubjectRead }} />
|
|
42
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
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 { participantSendStatesReducer } from './participantSendStates'
|
|
10
|
+
import { buildParticipantThemeStyle } from './participantTheme'
|
|
11
|
+
import type { UseParticipantInboxResult } from './useParticipantInbox'
|
|
12
|
+
|
|
13
|
+
export type ParticipantConversationsScreenProps = ParticipantConversationsProps & {
|
|
14
|
+
readonly inbox: Pick<UseParticipantInboxResult, 'status' | 'conversations' | 'hasMore' | 'refresh' | 'loadMore' | 'markSubjectRead'>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ALL_FILTER = 'all'
|
|
18
|
+
|
|
19
|
+
export function ParticipantConversationsScreen(props: ParticipantConversationsScreenProps) {
|
|
20
|
+
const { selected, inbox, subjectGroups, classNames } = props
|
|
21
|
+
const [filter, setFilter] = useState(ALL_FILTER)
|
|
22
|
+
const draftsRef = useRef<ParticipantDrafts>(new Map())
|
|
23
|
+
const [sendStates, dispatchSendStates] = useReducer(participantSendStatesReducer, new Map())
|
|
24
|
+
const labels = useMemo(
|
|
25
|
+
() => ({ ...DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS, ...props.labels }),
|
|
26
|
+
[props.labels],
|
|
27
|
+
)
|
|
28
|
+
const view = useMemo(
|
|
29
|
+
() => groupParticipantConversations({ conversations: inbox.conversations, subjectGroups, filter }),
|
|
30
|
+
[inbox.conversations, subjectGroups, filter],
|
|
31
|
+
)
|
|
32
|
+
const rootClassName = ['cv-p-root', props.className, classNames?.root].filter(Boolean).join(' ')
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className={rootClassName} style={buildParticipantThemeStyle(props.theme)}>
|
|
36
|
+
{selected === undefined ? (
|
|
37
|
+
<div className={['cv-p-root__screen', classNames?.inbox].filter(Boolean).join(' ')}>
|
|
38
|
+
<ParticipantInbox
|
|
39
|
+
view={view}
|
|
40
|
+
subjectGroups={subjectGroups}
|
|
41
|
+
filter={filter}
|
|
42
|
+
onFilterChange={setFilter}
|
|
43
|
+
onSelect={props.onSelect}
|
|
44
|
+
labels={labels}
|
|
45
|
+
status={inbox.status}
|
|
46
|
+
refresh={() => void inbox.refresh()}
|
|
47
|
+
hasMore={inbox.hasMore}
|
|
48
|
+
loadMore={() => void inbox.loadMore()}
|
|
49
|
+
locale={props.locale}
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
) : (
|
|
53
|
+
<div className={['cv-p-root__screen', classNames?.thread].filter(Boolean).join(' ')}>
|
|
54
|
+
<ParticipantThreadScreen
|
|
55
|
+
key={draftKey(selected)}
|
|
56
|
+
{...props}
|
|
57
|
+
selected={selected}
|
|
58
|
+
labels={labels}
|
|
59
|
+
draftsRef={draftsRef}
|
|
60
|
+
sendStates={sendStates}
|
|
61
|
+
dispatchSendStates={dispatchSendStates}
|
|
62
|
+
/>
|
|
63
|
+
</div>
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
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 } 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({ subjectId: '1', subjectLabel: 'Awaiting one', awaitingParticipant: true, unreadCount: 2, lastMessagePreview: 'Please send the photo' }),
|
|
17
|
+
buildConversation({ subjectId: '2', subjectLabel: 'Invoice two' }),
|
|
18
|
+
buildConversation({ subjectId: '3', subjectType: 'incident', subjectLabel: 'Incident three' }),
|
|
19
|
+
buildConversation({ subjectId: '4', subjectLabel: 'Old one', status: 'closed' }),
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
type RenderOverrides = {
|
|
23
|
+
conversations?: typeof CONVERSATIONS
|
|
24
|
+
filter?: string
|
|
25
|
+
status?: 'idle' | 'loading' | 'ready' | 'error'
|
|
26
|
+
hasMore?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function render(overrides: RenderOverrides = {}): string {
|
|
30
|
+
const view = groupParticipantConversations({
|
|
31
|
+
conversations: overrides.conversations ?? CONVERSATIONS,
|
|
32
|
+
subjectGroups: GROUPS,
|
|
33
|
+
filter: overrides.filter,
|
|
34
|
+
})
|
|
35
|
+
return renderToStaticMarkup(
|
|
36
|
+
<ParticipantInbox
|
|
37
|
+
view={view}
|
|
38
|
+
subjectGroups={GROUPS}
|
|
39
|
+
filter={overrides.filter ?? 'all'}
|
|
40
|
+
onFilterChange={() => undefined}
|
|
41
|
+
onSelect={() => undefined}
|
|
42
|
+
labels={DEFAULT_PARTICIPANT_CONVERSATIONS_LABELS}
|
|
43
|
+
status={overrides.status ?? 'ready'}
|
|
44
|
+
refresh={() => undefined}
|
|
45
|
+
hasMore={overrides.hasMore ?? false}
|
|
46
|
+
loadMore={() => undefined}
|
|
47
|
+
/>,
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('ParticipantInbox', () => {
|
|
52
|
+
it('renders section headers with label and count, awaiting highlighted', () => {
|
|
53
|
+
const markup = render()
|
|
54
|
+
|
|
55
|
+
expect(markup).toContain('Waiting for your reply')
|
|
56
|
+
expect(markup).toContain('cv-p-section--awaiting')
|
|
57
|
+
expect(markup).toContain('Invoice')
|
|
58
|
+
expect(markup).toContain('Incident')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('renders rows as buttons with the subject label as text and the preview', () => {
|
|
62
|
+
const markup = render()
|
|
63
|
+
|
|
64
|
+
expect(markup).toContain('<button')
|
|
65
|
+
expect(markup).toContain('Awaiting one')
|
|
66
|
+
expect(markup).toContain('Please send the photo')
|
|
67
|
+
expect(markup).toContain('cv-p-row__kind')
|
|
68
|
+
expect(markup).toContain('cv-p-row--awaiting')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('shows an unread badge with accessible text only when there are unread messages', () => {
|
|
72
|
+
const markup = render()
|
|
73
|
+
|
|
74
|
+
expect(markup).toContain('2 unread')
|
|
75
|
+
expect(markup.match(/cv-p-row__unread/g)?.length).toBe(1)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('puts closed conversations inside a collapsible details', () => {
|
|
79
|
+
const markup = render()
|
|
80
|
+
|
|
81
|
+
expect(markup).toContain('<details')
|
|
82
|
+
expect(markup).toContain('Closed')
|
|
83
|
+
expect(markup).toContain('Old one')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('renders filters only when showFilters is true', () => {
|
|
87
|
+
const withFilters = render()
|
|
88
|
+
const single = render({ conversations: [CONVERSATIONS[1] as (typeof CONVERSATIONS)[number]] })
|
|
89
|
+
|
|
90
|
+
expect(withFilters).toContain('cv-p-filters')
|
|
91
|
+
expect(withFilters).toContain('aria-pressed="true"')
|
|
92
|
+
expect(single).not.toContain('cv-p-filters')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('renders the empty state when there are no sections', () => {
|
|
96
|
+
const markup = render({ conversations: [] })
|
|
97
|
+
|
|
98
|
+
expect(markup).toContain('No conversations yet')
|
|
99
|
+
expect(markup).not.toContain('cv-p-row"')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('while loading shows a busy skeleton and never the empty state', () => {
|
|
103
|
+
const markup = render({ conversations: [], status: 'loading' })
|
|
104
|
+
|
|
105
|
+
expect(markup).toContain('aria-busy="true"')
|
|
106
|
+
expect(markup).toContain('Loading…')
|
|
107
|
+
expect(markup).not.toContain('No conversations yet')
|
|
108
|
+
expect(render({ conversations: [], status: 'idle' })).not.toContain('No conversations yet')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('on error shows an alert with retry and never the empty state', () => {
|
|
112
|
+
const markup = render({ conversations: [], status: 'error' })
|
|
113
|
+
|
|
114
|
+
expect(markup).toContain('role="alert"')
|
|
115
|
+
expect(markup).toContain('Could not load')
|
|
116
|
+
expect(markup).toContain('Retry')
|
|
117
|
+
expect(markup).not.toContain('No conversations yet')
|
|
118
|
+
expect(markup).not.toContain('aria-busy="true"')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('on success shows the empty state without busy or alert', () => {
|
|
122
|
+
const markup = render({ conversations: [], status: 'ready' })
|
|
123
|
+
|
|
124
|
+
expect(markup).toContain('No conversations yet')
|
|
125
|
+
expect(markup).not.toContain('role="alert"')
|
|
126
|
+
expect(markup).toContain('aria-busy="false"')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('keeps the rows and adds the alert when a later request fails', () => {
|
|
130
|
+
const markup = render({ status: 'error' })
|
|
131
|
+
|
|
132
|
+
expect(markup).toContain('Awaiting one')
|
|
133
|
+
expect(markup).toContain('role="alert"')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('shows load more only when there is a next page', () => {
|
|
137
|
+
expect(render()).not.toContain('Load more')
|
|
138
|
+
expect(render({ hasMore: true })).toContain('Load more')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('uses only cv-p-* classes and no Tailwind utilities', () => {
|
|
142
|
+
const markup = render()
|
|
143
|
+
|
|
144
|
+
expect(findUtilityClassTokens(markup)).toEqual([])
|
|
145
|
+
expect(findForeignClassTokens(markup)).toEqual([])
|
|
146
|
+
})
|
|
147
|
+
})
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { ParticipantConversationSummary, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
|
|
2
|
+
|
|
3
|
+
import { formatTimestamp, isSameDay } from '../lib/format'
|
|
4
|
+
import type {
|
|
5
|
+
ParticipantInboxFilter,
|
|
6
|
+
ParticipantInboxSection,
|
|
7
|
+
ParticipantInboxView,
|
|
8
|
+
ParticipantSubjectGroup,
|
|
9
|
+
} from './participant.types'
|
|
10
|
+
import { formatParticipantLabel, type ParticipantConversationsLabels } from './participantLabels'
|
|
11
|
+
import { resolveLoadView, type ParticipantLoadStatus } from './participantLoadView'
|
|
12
|
+
import { ParticipantLoadError, ParticipantLoading } from './ParticipantLoadState'
|
|
13
|
+
|
|
14
|
+
export type ParticipantInboxProps = {
|
|
15
|
+
readonly view: ParticipantInboxView
|
|
16
|
+
readonly subjectGroups: readonly ParticipantSubjectGroup[]
|
|
17
|
+
/** subjectType of the active filter, or 'all'. */
|
|
18
|
+
readonly filter: string
|
|
19
|
+
readonly onFilterChange: (filter: string) => void
|
|
20
|
+
readonly onSelect: (subject: ParticipantSubjectRef) => void
|
|
21
|
+
readonly labels: ParticipantConversationsLabels
|
|
22
|
+
readonly status: ParticipantLoadStatus
|
|
23
|
+
readonly refresh: () => void
|
|
24
|
+
readonly hasMore: boolean
|
|
25
|
+
readonly loadMore: () => void
|
|
26
|
+
readonly locale?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ALL_FILTER = 'all'
|
|
30
|
+
|
|
31
|
+
function formatRowTime(iso: string, locale: string | undefined): string {
|
|
32
|
+
const date = new Date(iso)
|
|
33
|
+
if (isSameDay(date, new Date())) return formatTimestamp(iso)
|
|
34
|
+
return date.toLocaleDateString(locale, { day: '2-digit', month: '2-digit' })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sectionTitle(section: ParticipantInboxSection, props: ParticipantInboxProps): string {
|
|
38
|
+
if (section.key === 'awaiting') return props.labels.sectionAwaiting
|
|
39
|
+
if (section.key === 'closed') return props.labels.sectionClosed
|
|
40
|
+
if (section.key === 'other') return props.labels.sectionOther
|
|
41
|
+
return props.subjectGroups.find((group) => group.subjectType === section.key)?.label ?? section.key
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type FiltersProps = {
|
|
45
|
+
readonly filters: readonly ParticipantInboxFilter[]
|
|
46
|
+
readonly active: string
|
|
47
|
+
readonly labels: ParticipantConversationsLabels
|
|
48
|
+
readonly onChange: (filter: string) => void
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function Filters({ filters, active, labels, onChange }: FiltersProps) {
|
|
52
|
+
return (
|
|
53
|
+
<div className="cv-p-filters" role="group" aria-label={labels.filtersGroup}>
|
|
54
|
+
{filters.map((filter) => (
|
|
55
|
+
<button
|
|
56
|
+
key={filter.subjectType}
|
|
57
|
+
type="button"
|
|
58
|
+
className="cv-p-chip"
|
|
59
|
+
aria-pressed={filter.subjectType === active}
|
|
60
|
+
onClick={() => onChange(filter.subjectType)}
|
|
61
|
+
>
|
|
62
|
+
{filter.subjectType === ALL_FILTER ? labels.filterAll : filter.label}
|
|
63
|
+
{filter.unreadCount > 0 ? <b className="cv-p-chip__count">{filter.unreadCount}</b> : null}
|
|
64
|
+
</button>
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
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
|
+
function SectionHeading({ title, count }: { readonly title: string; readonly count: number }) {
|
|
107
|
+
return (
|
|
108
|
+
<>
|
|
109
|
+
<span className="cv-p-section__title">{title}</span>
|
|
110
|
+
<span className="cv-p-section__count">{count}</span>
|
|
111
|
+
</>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function Section({ section, props }: { readonly section: ParticipantInboxSection; readonly props: ParticipantInboxProps }) {
|
|
116
|
+
const rows = section.conversations.map((conversation) => (
|
|
117
|
+
<Row
|
|
118
|
+
key={`${conversation.subjectType}:${conversation.subjectId}`}
|
|
119
|
+
conversation={conversation}
|
|
120
|
+
group={props.subjectGroups.find((group) => group.subjectType === conversation.subjectType)}
|
|
121
|
+
labels={props.labels}
|
|
122
|
+
locale={props.locale}
|
|
123
|
+
onSelect={props.onSelect}
|
|
124
|
+
/>
|
|
125
|
+
))
|
|
126
|
+
const heading = <SectionHeading title={sectionTitle(section, props)} count={section.conversations.length} />
|
|
127
|
+
|
|
128
|
+
if (section.key === 'closed') {
|
|
129
|
+
return (
|
|
130
|
+
<details className="cv-p-section cv-p-section--closed">
|
|
131
|
+
<summary className="cv-p-section__heading">{heading}</summary>
|
|
132
|
+
{rows}
|
|
133
|
+
</details>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
const modifier = section.key === 'awaiting' ? ' cv-p-section--awaiting' : ''
|
|
137
|
+
return (
|
|
138
|
+
<section className={`cv-p-section${modifier}`}>
|
|
139
|
+
<h3 className="cv-p-section__heading">{heading}</h3>
|
|
140
|
+
{rows}
|
|
141
|
+
</section>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function ParticipantInbox(props: ParticipantInboxProps) {
|
|
146
|
+
const { view, labels, filter, onFilterChange } = props
|
|
147
|
+
const loadView = resolveLoadView({ status: props.status, hasItems: view.sections.length > 0 })
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div className="cv-p cv-p-inbox" aria-busy={loadView.isLoading}>
|
|
151
|
+
<h2 className="cv-p-inbox__title">{labels.inboxTitle}</h2>
|
|
152
|
+
{view.showFilters ? (
|
|
153
|
+
<Filters filters={view.filters} active={filter} labels={labels} onChange={onFilterChange} />
|
|
154
|
+
) : null}
|
|
155
|
+
{loadView.isLoading ? <ParticipantLoading labels={labels} /> : null}
|
|
156
|
+
{loadView.hasError ? <ParticipantLoadError labels={labels} onRetry={props.refresh} /> : null}
|
|
157
|
+
{loadView.isEmpty ? <p className="cv-p-empty">{labels.emptyInbox}</p> : null}
|
|
158
|
+
{view.sections.map((section) => (
|
|
159
|
+
<Section key={section.key} section={section} props={props} />
|
|
160
|
+
))}
|
|
161
|
+
{props.hasMore ? (
|
|
162
|
+
<button type="button" className="cv-p-button cv-p-inbox__more" onClick={props.loadMore}>
|
|
163
|
+
{labels.loadMore}
|
|
164
|
+
</button>
|
|
165
|
+
) : null}
|
|
166
|
+
</div>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ParticipantConversationsLabels } from './participantLabels'
|
|
2
|
+
|
|
3
|
+
export type ParticipantLoadErrorProps = {
|
|
4
|
+
readonly labels: ParticipantConversationsLabels
|
|
5
|
+
readonly onRetry: () => void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function ParticipantLoadError({ labels, onRetry }: ParticipantLoadErrorProps) {
|
|
9
|
+
return (
|
|
10
|
+
<div className="cv-p-error" role="alert">
|
|
11
|
+
<p className="cv-p-error__message">{labels.loadError}</p>
|
|
12
|
+
<button type="button" className="cv-p-button" onClick={onRetry}>
|
|
13
|
+
{labels.retry}
|
|
14
|
+
</button>
|
|
15
|
+
</div>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function ParticipantLoading({ labels }: { readonly labels: ParticipantConversationsLabels }) {
|
|
20
|
+
return (
|
|
21
|
+
<div className="cv-p-loading">
|
|
22
|
+
<span className="cv-p-skeleton" aria-hidden="true" />
|
|
23
|
+
<span className="cv-p-skeleton" aria-hidden="true" />
|
|
24
|
+
<span className="cv-p-sr-only">{labels.loading}</span>
|
|
25
|
+
</div>
|
|
26
|
+
)
|
|
27
|
+
}
|