@proteos/ui 0.50.1 → 0.51.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/dist/index.cjs +659 -304
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +230 -8
- package/dist/index.d.ts +230 -8
- package/dist/index.js +651 -307
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/components/badge/badge.stories.tsx +25 -0
- package/src/components/badge/badge.test.tsx +22 -0
- package/src/components/badge/badge.tsx +41 -10
- package/src/components/badge-multi-select/badge-multi-select.tsx +16 -11
- package/src/components/conversation-brief-card/conversation-brief-card.stories.tsx +81 -0
- package/src/components/conversation-brief-card/conversation-brief-card.tsx +102 -0
- package/src/components/conversation-brief-card/conversation-brief-preview.tsx +156 -0
- package/src/components/conversation-brief-card/conversation-brief-review.test.tsx +214 -0
- package/src/components/conversation-brief-card/conversation-brief-review.tsx +192 -0
- package/src/components/conversation-brief-card/create-conversation-brief-client.ts +18 -0
- package/src/components/conversation-brief-card/index.ts +24 -0
- package/src/components/conversation-brief-card/types.ts +50 -0
- package/src/components/data-table/data-table.test.tsx +62 -0
- package/src/components/data-table/data-table.tsx +75 -7
- package/src/components/record-filter/chip-value.tsx +2 -0
- package/src/index.ts +1 -0
- package/src/styles.css +1 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { Contact, ConversationBrief } from '@proteos/sdk'
|
|
2
|
+
import { render, screen, waitFor } from '@testing-library/react'
|
|
3
|
+
import userEvent from '@testing-library/user-event'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { ConversationBriefCard } from './conversation-brief-card'
|
|
7
|
+
import { ConversationBriefPreview } from './conversation-brief-preview'
|
|
8
|
+
import { ConversationBriefReview } from './conversation-brief-review'
|
|
9
|
+
import { type ConversationBriefClient, contactPhoneNumbers } from './types'
|
|
10
|
+
|
|
11
|
+
function brief(overrides: Partial<ConversationBrief> = {}): ConversationBrief {
|
|
12
|
+
return {
|
|
13
|
+
id: 'brief-1',
|
|
14
|
+
org_id: 'org-1',
|
|
15
|
+
contact_ids: ['contact-1'],
|
|
16
|
+
subject: '',
|
|
17
|
+
type_key: 'renewal',
|
|
18
|
+
status: 'prepared',
|
|
19
|
+
content: 'Ask about the renewal.\nMention the Q4 discount.',
|
|
20
|
+
created_at: '2026-09-09T10:00:00Z',
|
|
21
|
+
created_by: { type: 'agent', id: 'agent-1' },
|
|
22
|
+
updated_at: '2026-09-09T10:00:00Z',
|
|
23
|
+
updated_by: { type: 'agent', id: 'agent-1' },
|
|
24
|
+
...overrides,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function contact(overrides: Partial<Contact> = {}): Contact {
|
|
29
|
+
return {
|
|
30
|
+
id: 'contact-1',
|
|
31
|
+
name: 'Alice Example',
|
|
32
|
+
addresses: [
|
|
33
|
+
{ id: 'a1', kind: 'email', value: 'alice@example.com' },
|
|
34
|
+
{ id: 'a2', kind: 'phone', value: '+4930123456' },
|
|
35
|
+
{ id: 'a3', kind: 'phone', value: '+4917612345' },
|
|
36
|
+
],
|
|
37
|
+
...overrides,
|
|
38
|
+
} as Contact
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function mockClient(overrides: Partial<ConversationBriefClient> = {}): ConversationBriefClient {
|
|
42
|
+
return {
|
|
43
|
+
getConversationBrief: vi.fn().mockResolvedValue(brief()),
|
|
44
|
+
getContact: vi.fn().mockResolvedValue(contact()),
|
|
45
|
+
...overrides,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('contactPhoneNumbers', () => {
|
|
50
|
+
it('keeps only phone addresses, deduplicated', () => {
|
|
51
|
+
expect(contactPhoneNumbers(contact())).toEqual(['+4930123456', '+4917612345'])
|
|
52
|
+
expect(contactPhoneNumbers(null)).toEqual([])
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('ConversationBriefCard', () => {
|
|
57
|
+
it('renders the action while open and the outcome label once terminal', () => {
|
|
58
|
+
const onOpen = vi.fn()
|
|
59
|
+
const { rerender } = render(
|
|
60
|
+
<ConversationBriefCard title="Alice" summary="renewal" onOpen={onOpen} />,
|
|
61
|
+
)
|
|
62
|
+
expect(screen.getByRole('button', { name: 'Open Brief' })).toBeInTheDocument()
|
|
63
|
+
rerender(<ConversationBriefCard title="Alice" outcome="used" />)
|
|
64
|
+
expect(screen.queryByRole('button', { name: 'Open Brief' })).not.toBeInTheDocument()
|
|
65
|
+
expect(screen.getByText('Call held')).toBeInTheDocument()
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('ConversationBriefReview', () => {
|
|
70
|
+
it('loads the brief + contact and hands Open Brief to the host seam', async () => {
|
|
71
|
+
const onOpenCall = vi.fn()
|
|
72
|
+
render(
|
|
73
|
+
<ConversationBriefReview
|
|
74
|
+
conversationBriefId="brief-1"
|
|
75
|
+
client={mockClient()}
|
|
76
|
+
onOpenCall={onOpenCall}
|
|
77
|
+
/>,
|
|
78
|
+
)
|
|
79
|
+
expect(await screen.findByText('Alice Example')).toBeInTheDocument()
|
|
80
|
+
expect(screen.getByText('renewal · Ask about the renewal.')).toBeInTheDocument()
|
|
81
|
+
await userEvent.click(screen.getByRole('button', { name: 'Open Brief' }))
|
|
82
|
+
expect(onOpenCall).toHaveBeenCalledWith('brief-1')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('opens the built-in preview and dials through onCall', async () => {
|
|
86
|
+
const onCall = vi.fn()
|
|
87
|
+
render(
|
|
88
|
+
<ConversationBriefReview
|
|
89
|
+
conversationBriefId="brief-1"
|
|
90
|
+
client={mockClient()}
|
|
91
|
+
onCall={onCall}
|
|
92
|
+
/>,
|
|
93
|
+
)
|
|
94
|
+
await userEvent.click(await screen.findByRole('button', { name: 'Open Brief' }))
|
|
95
|
+
expect(await screen.findByRole('dialog')).toBeInTheDocument()
|
|
96
|
+
expect(screen.getByText(/Mention the Q4 discount/)).toBeInTheDocument()
|
|
97
|
+
await userEvent.click(screen.getByRole('button', { name: 'Call' }))
|
|
98
|
+
expect(onCall).toHaveBeenCalledWith(
|
|
99
|
+
expect.objectContaining({ id: 'brief-1' }),
|
|
100
|
+
expect.objectContaining({ id: 'contact-1' }),
|
|
101
|
+
'+4930123456',
|
|
102
|
+
)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('settles immediately when the brief is already used', async () => {
|
|
106
|
+
const onOutcome = vi.fn()
|
|
107
|
+
const client = mockClient({
|
|
108
|
+
getConversationBrief: vi.fn().mockResolvedValue(brief({ status: 'used' })),
|
|
109
|
+
})
|
|
110
|
+
render(
|
|
111
|
+
<ConversationBriefReview
|
|
112
|
+
conversationBriefId="brief-1"
|
|
113
|
+
client={client}
|
|
114
|
+
onOutcome={onOutcome}
|
|
115
|
+
/>,
|
|
116
|
+
)
|
|
117
|
+
await waitFor(() => expect(onOutcome).toHaveBeenCalledTimes(1))
|
|
118
|
+
expect(onOutcome).toHaveBeenCalledWith('used', expect.objectContaining({ status: 'used' }))
|
|
119
|
+
expect(screen.getByText('Call held')).toBeInTheDocument()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('reports a load error and renders nothing', async () => {
|
|
123
|
+
const onLoadError = vi.fn()
|
|
124
|
+
const client = mockClient({
|
|
125
|
+
getConversationBrief: vi.fn().mockRejectedValue(new Error('nope')),
|
|
126
|
+
})
|
|
127
|
+
const { container } = render(
|
|
128
|
+
<ConversationBriefReview
|
|
129
|
+
conversationBriefId="brief-1"
|
|
130
|
+
client={client}
|
|
131
|
+
onLoadError={onLoadError}
|
|
132
|
+
/>,
|
|
133
|
+
)
|
|
134
|
+
await waitFor(() => expect(onLoadError).toHaveBeenCalled())
|
|
135
|
+
expect(container).toBeEmptyDOMElement()
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('ConversationBriefReview subject + channel', () => {
|
|
140
|
+
it('leads with the subject and labels a meeting brief Open Brief', async () => {
|
|
141
|
+
const client = mockClient({
|
|
142
|
+
getConversationBrief: vi
|
|
143
|
+
.fn()
|
|
144
|
+
.mockResolvedValue(brief({ subject: 'Renewal on Zoom', channel: 'zoom-meeting' })),
|
|
145
|
+
})
|
|
146
|
+
render(
|
|
147
|
+
<ConversationBriefReview conversationBriefId="brief-1" client={client} onCall={vi.fn()} />,
|
|
148
|
+
)
|
|
149
|
+
expect(await screen.findByText('Renewal on Zoom')).toBeInTheDocument()
|
|
150
|
+
expect(
|
|
151
|
+
screen.getByText('Alice Example · zoom-meeting · renewal · Ask about the renewal.'),
|
|
152
|
+
).toBeInTheDocument()
|
|
153
|
+
await userEvent.click(screen.getByRole('button', { name: 'Open Brief' }))
|
|
154
|
+
expect(await screen.findByRole('dialog')).toBeInTheDocument()
|
|
155
|
+
// A meeting brief is not dialable from the platform — no Call button.
|
|
156
|
+
expect(screen.queryByRole('button', { name: 'Call' })).not.toBeInTheDocument()
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe('ConversationBriefPreview', () => {
|
|
161
|
+
it('offers a who-to-call pick for several contacts', async () => {
|
|
162
|
+
const onCall = vi.fn()
|
|
163
|
+
const tom = contact({
|
|
164
|
+
id: 'contact-2',
|
|
165
|
+
name: 'Tom Example',
|
|
166
|
+
addresses: [{ id: 'b1', kind: 'phone', value: '+4915500000' }],
|
|
167
|
+
} as Partial<Contact>)
|
|
168
|
+
render(
|
|
169
|
+
<ConversationBriefPreview
|
|
170
|
+
brief={brief({ contact_ids: ['contact-1', 'contact-2'] })}
|
|
171
|
+
contacts={[contact(), tom]}
|
|
172
|
+
onCall={onCall}
|
|
173
|
+
/>,
|
|
174
|
+
)
|
|
175
|
+
expect(screen.getByRole('button', { name: /Who to call/ })).toBeInTheDocument()
|
|
176
|
+
await userEvent.click(screen.getByRole('button', { name: 'Call' }))
|
|
177
|
+
expect(onCall).toHaveBeenCalledWith(expect.objectContaining({ id: 'contact-1' }), '+4930123456')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('can hide its Call button and report the target to the host', () => {
|
|
181
|
+
const onSelectionChange = vi.fn()
|
|
182
|
+
render(
|
|
183
|
+
<ConversationBriefPreview
|
|
184
|
+
brief={brief()}
|
|
185
|
+
contacts={[contact()]}
|
|
186
|
+
onCall={vi.fn()}
|
|
187
|
+
isCallButtonHidden
|
|
188
|
+
onSelectionChange={onSelectionChange}
|
|
189
|
+
/>,
|
|
190
|
+
)
|
|
191
|
+
expect(screen.queryByRole('button', { name: 'Call' })).not.toBeInTheDocument()
|
|
192
|
+
expect(onSelectionChange).toHaveBeenCalledWith(
|
|
193
|
+
expect.objectContaining({ id: 'contact-1' }),
|
|
194
|
+
'+4930123456',
|
|
195
|
+
)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('is read-only without onCall', () => {
|
|
199
|
+
render(<ConversationBriefPreview brief={brief()} contacts={[contact()]} />)
|
|
200
|
+
expect(screen.queryByRole('button', { name: 'Call' })).not.toBeInTheDocument()
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('disables Call when the contact has no number', () => {
|
|
204
|
+
render(
|
|
205
|
+
<ConversationBriefPreview
|
|
206
|
+
brief={brief()}
|
|
207
|
+
contacts={[contact({ addresses: [] })]}
|
|
208
|
+
onCall={vi.fn()}
|
|
209
|
+
/>,
|
|
210
|
+
)
|
|
211
|
+
expect(screen.getByRole('button', { name: 'Call' })).toBeDisabled()
|
|
212
|
+
expect(screen.getByText(/no phone number/)).toBeInTheDocument()
|
|
213
|
+
})
|
|
214
|
+
})
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { Contact, ConversationBrief } from '@proteos/sdk'
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
3
|
+
|
|
4
|
+
import { ModalBody, ModalContent, ModalHeader, ModalTitle } from '../modal'
|
|
5
|
+
import { ConversationBriefCard } from './conversation-brief-card'
|
|
6
|
+
import { ConversationBriefPreview } from './conversation-brief-preview'
|
|
7
|
+
import {
|
|
8
|
+
type ConversationBriefClient,
|
|
9
|
+
type ConversationBriefOutcome,
|
|
10
|
+
conversationBriefOutcomeFor,
|
|
11
|
+
isDialableConversationBrief,
|
|
12
|
+
} from './types'
|
|
13
|
+
|
|
14
|
+
/* =========================================================================
|
|
15
|
+
ConversationBriefReview — the connected prepared-call entry point: loads the brief
|
|
16
|
+
(and its contact) through the injected client, renders ConversationBriefCard, and
|
|
17
|
+
owns what "Open Brief" does.
|
|
18
|
+
|
|
19
|
+
The open action is the seam. Left alone it opens a modal with the
|
|
20
|
+
ConversationBriefPreview — a read-only preview unless the host passes `onCall`
|
|
21
|
+
(the web app does: it dials through its softphone). Passed `onOpenCall`,
|
|
22
|
+
it hands the whole interaction to the host: the built-in modal is never
|
|
23
|
+
rendered. That is how a sandboxed custom component opens a prepared call —
|
|
24
|
+
an iframe cannot dial, so it asks the host (useOpenCall) to preview + dial.
|
|
25
|
+
|
|
26
|
+
A brief that is ALREADY terminal when it loads (used on a call, discarded)
|
|
27
|
+
settles the card immediately and reports `onOutcome` once.
|
|
28
|
+
========================================================================= */
|
|
29
|
+
|
|
30
|
+
export interface ConversationBriefReviewProps {
|
|
31
|
+
conversationBriefId: string
|
|
32
|
+
/** Injected fetch surface — see createConversationBriefClient. */
|
|
33
|
+
client: ConversationBriefClient
|
|
34
|
+
/**
|
|
35
|
+
* FULL REPLACEMENT for the open action's default (the built-in preview
|
|
36
|
+
* modal). When supplied the built-in modal is never rendered.
|
|
37
|
+
*/
|
|
38
|
+
onOpenCall?: (conversationBriefId: string) => void
|
|
39
|
+
/** Built-in modal only: place the call to this contact + number (the
|
|
40
|
+
* host's dial). Absent ⇒ the modal is a read-only preview. */
|
|
41
|
+
onCall?: (brief: ConversationBrief, contact: Contact, phoneNumber: string) => void
|
|
42
|
+
/** Built-in modal only: disable the Call button + explain why. */
|
|
43
|
+
isCallDisabled?: boolean
|
|
44
|
+
callDisabledReason?: string
|
|
45
|
+
/** Fires at most once, when the brief is (or becomes) terminal. */
|
|
46
|
+
onOutcome?: (outcome: ConversationBriefOutcome, brief: ConversationBrief) => void
|
|
47
|
+
/** The brief could not be loaded. The card renders nothing in that case. */
|
|
48
|
+
onLoadError?: (error: Error) => void
|
|
49
|
+
/** Built-in modal heading. Default 'Prepared conversation'. */
|
|
50
|
+
modalTitle?: string
|
|
51
|
+
modalClassName?: string
|
|
52
|
+
/** Built-in modal overlay classes — where a host raises the z tier. */
|
|
53
|
+
overlayClassName?: string
|
|
54
|
+
openLabel?: string
|
|
55
|
+
className?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function ConversationBriefReview({
|
|
59
|
+
conversationBriefId,
|
|
60
|
+
client,
|
|
61
|
+
onOpenCall,
|
|
62
|
+
onCall,
|
|
63
|
+
isCallDisabled = false,
|
|
64
|
+
callDisabledReason,
|
|
65
|
+
onOutcome,
|
|
66
|
+
onLoadError,
|
|
67
|
+
modalTitle = 'Prepared conversation',
|
|
68
|
+
modalClassName = 'max-w-2xl',
|
|
69
|
+
overlayClassName,
|
|
70
|
+
openLabel,
|
|
71
|
+
className,
|
|
72
|
+
}: ConversationBriefReviewProps) {
|
|
73
|
+
const [brief, setBrief] = useState<ConversationBrief | null>(null)
|
|
74
|
+
const [contacts, setContacts] = useState<Contact[]>([])
|
|
75
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
76
|
+
const [outcome, setOutcome] = useState<ConversationBriefOutcome | null>(null)
|
|
77
|
+
|
|
78
|
+
const onOutcomeRef = useRef(onOutcome)
|
|
79
|
+
onOutcomeRef.current = onOutcome
|
|
80
|
+
const onLoadErrorRef = useRef(onLoadError)
|
|
81
|
+
onLoadErrorRef.current = onLoadError
|
|
82
|
+
|
|
83
|
+
const hasSettledRef = useRef(false)
|
|
84
|
+
const settle = useCallback((next: ConversationBriefOutcome, settled: ConversationBrief) => {
|
|
85
|
+
setOutcome(next)
|
|
86
|
+
setIsOpen(false)
|
|
87
|
+
if (hasSettledRef.current) return
|
|
88
|
+
hasSettledRef.current = true
|
|
89
|
+
onOutcomeRef.current?.(next, settled)
|
|
90
|
+
}, [])
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
let cancelled = false
|
|
94
|
+
void (async () => {
|
|
95
|
+
try {
|
|
96
|
+
const loaded = await client.getConversationBrief(conversationBriefId)
|
|
97
|
+
if (cancelled) return
|
|
98
|
+
setBrief(loaded)
|
|
99
|
+
const terminal = conversationBriefOutcomeFor(loaded.status)
|
|
100
|
+
if (terminal) settle(terminal, loaded)
|
|
101
|
+
const people = await Promise.all(
|
|
102
|
+
loaded.contact_ids.map((contactId) => client.getContact(contactId).catch(() => null)),
|
|
103
|
+
)
|
|
104
|
+
if (!cancelled) setContacts(people.filter((person): person is Contact => person !== null))
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (cancelled) return
|
|
107
|
+
onLoadErrorRef.current?.(error instanceof Error ? error : new Error(String(error)))
|
|
108
|
+
}
|
|
109
|
+
})()
|
|
110
|
+
return () => {
|
|
111
|
+
cancelled = true
|
|
112
|
+
}
|
|
113
|
+
}, [client, conversationBriefId, settle])
|
|
114
|
+
|
|
115
|
+
const open = () => {
|
|
116
|
+
if (onOpenCall) {
|
|
117
|
+
onOpenCall(conversationBriefId)
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
setIsOpen(true)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!brief) return null
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<>
|
|
127
|
+
<ConversationBriefCard
|
|
128
|
+
title={brief.subject || namesOf(contacts) || 'Prepared conversation'}
|
|
129
|
+
summary={summaryFor(brief, contacts)}
|
|
130
|
+
outcome={outcome}
|
|
131
|
+
onOpen={open}
|
|
132
|
+
{...(openLabel !== undefined ? { openLabel } : {})}
|
|
133
|
+
{...(className !== undefined ? { className } : {})}
|
|
134
|
+
/>
|
|
135
|
+
{onOpenCall ? null : (
|
|
136
|
+
<ModalContent
|
|
137
|
+
isOpen={isOpen}
|
|
138
|
+
onOpenChange={setIsOpen}
|
|
139
|
+
modalClassName={modalClassName}
|
|
140
|
+
{...(overlayClassName !== undefined ? { className: overlayClassName } : {})}
|
|
141
|
+
>
|
|
142
|
+
<ModalHeader>
|
|
143
|
+
<ModalTitle>{modalTitle}</ModalTitle>
|
|
144
|
+
</ModalHeader>
|
|
145
|
+
<ModalBody className="min-h-0 overflow-y-auto">
|
|
146
|
+
<ConversationBriefPreview
|
|
147
|
+
brief={brief}
|
|
148
|
+
contacts={contacts}
|
|
149
|
+
contentClassName="max-h-[50dvh]"
|
|
150
|
+
isCallDisabled={isCallDisabled}
|
|
151
|
+
{...(callDisabledReason !== undefined ? { callDisabledReason } : {})}
|
|
152
|
+
{...(onCall && isDialableConversationBrief(brief)
|
|
153
|
+
? {
|
|
154
|
+
onCall: (contact: Contact, phoneNumber: string) => {
|
|
155
|
+
setIsOpen(false)
|
|
156
|
+
onCall(brief, contact, phoneNumber)
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
: {})}
|
|
160
|
+
/>
|
|
161
|
+
</ModalBody>
|
|
162
|
+
</ModalContent>
|
|
163
|
+
)}
|
|
164
|
+
</>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The people's names, comma-joined. */
|
|
169
|
+
export function namesOf(contacts: Contact[]): string {
|
|
170
|
+
return contacts
|
|
171
|
+
.map((contact) => contact.name)
|
|
172
|
+
.filter(Boolean)
|
|
173
|
+
.join(', ')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Who + where + what: the people (when the subject took the headline),
|
|
177
|
+
* the channel, the type, then the opening line. */
|
|
178
|
+
export function summaryFor(brief: ConversationBrief, contacts: Contact[]): string {
|
|
179
|
+
const firstLine =
|
|
180
|
+
brief.content
|
|
181
|
+
.split('\n')
|
|
182
|
+
.find((line) => line.trim() !== '')
|
|
183
|
+
?.trim() ?? ''
|
|
184
|
+
return [
|
|
185
|
+
brief.subject ? namesOf(contacts) : '',
|
|
186
|
+
brief.channel ?? '',
|
|
187
|
+
brief.type_key ?? '',
|
|
188
|
+
firstLine,
|
|
189
|
+
]
|
|
190
|
+
.filter(Boolean)
|
|
191
|
+
.join(' · ')
|
|
192
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ConversationClient } from '@proteos/sdk'
|
|
2
|
+
|
|
3
|
+
import type { ConversationBriefClient } from './types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One-liner adapter from the sdk ConversationClient (web app:
|
|
7
|
+
* `useSdk().conversation`; custom components: `useSdk().conversations`) to
|
|
8
|
+
* the conversation-brief surfaces' injected client. Type-only sdk import.
|
|
9
|
+
*/
|
|
10
|
+
export function createConversationBriefClient(
|
|
11
|
+
conversation: Pick<ConversationClient, 'conversationBriefs' | 'contacts'>,
|
|
12
|
+
): ConversationBriefClient {
|
|
13
|
+
return {
|
|
14
|
+
getConversationBrief: (conversationBriefId) =>
|
|
15
|
+
conversation.conversationBriefs.get(conversationBriefId),
|
|
16
|
+
getContact: (contactId) => conversation.contacts.get(contactId),
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export {
|
|
2
|
+
CONVERSATION_BRIEF_OUTCOME_LABEL,
|
|
3
|
+
ConversationBriefCard,
|
|
4
|
+
type ConversationBriefCardProps,
|
|
5
|
+
conversationBriefCardStyles,
|
|
6
|
+
} from './conversation-brief-card'
|
|
7
|
+
export {
|
|
8
|
+
ConversationBriefPreview,
|
|
9
|
+
type ConversationBriefPreviewProps,
|
|
10
|
+
} from './conversation-brief-preview'
|
|
11
|
+
export {
|
|
12
|
+
ConversationBriefReview,
|
|
13
|
+
type ConversationBriefReviewProps,
|
|
14
|
+
namesOf,
|
|
15
|
+
summaryFor,
|
|
16
|
+
} from './conversation-brief-review'
|
|
17
|
+
export { createConversationBriefClient } from './create-conversation-brief-client'
|
|
18
|
+
export {
|
|
19
|
+
type ConversationBriefClient,
|
|
20
|
+
type ConversationBriefOutcome,
|
|
21
|
+
contactPhoneNumbers,
|
|
22
|
+
conversationBriefOutcomeFor,
|
|
23
|
+
isDialableConversationBrief,
|
|
24
|
+
} from './types'
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Contact, ConversationBrief } from '@proteos/sdk'
|
|
2
|
+
|
|
3
|
+
/* =========================================================================
|
|
4
|
+
ConversationBriefReview contracts — types-only @proteos/sdk imports, so the lib
|
|
5
|
+
stays fetch-agnostic (the DraftMessageComposer precedent): the host injects
|
|
6
|
+
the client, the component never talks to the network itself.
|
|
7
|
+
========================================================================= */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The minimal fetch surface the conversation-brief surfaces need, injected per host:
|
|
11
|
+
* the web app builds it from `useSdk().conversation`, custom components from
|
|
12
|
+
* `useSdk().conversations` — both via {@link createConversationBriefClient}.
|
|
13
|
+
*/
|
|
14
|
+
export interface ConversationBriefClient {
|
|
15
|
+
/** One brief — also resolves terminal states (used / discarded) by id. */
|
|
16
|
+
getConversationBrief(conversationBriefId: string): Promise<ConversationBrief>
|
|
17
|
+
/** One of the brief's contacts: name + addresses (the phone numbers to dial). */
|
|
18
|
+
getContact(contactId: string): Promise<Contact>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A brief's terminal state — what the card shows once it can no longer be
|
|
22
|
+
* opened for a call. */
|
|
23
|
+
export type ConversationBriefOutcome = 'used' | 'discarded'
|
|
24
|
+
|
|
25
|
+
/** The terminal outcome for a brief status, or null while it is open. */
|
|
26
|
+
export function conversationBriefOutcomeFor(
|
|
27
|
+
status: ConversationBrief['status'],
|
|
28
|
+
): ConversationBriefOutcome | null {
|
|
29
|
+
if (status === 'used' || status === 'discarded') return status
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Whether a brief can be turned into a phone call from the platform: pinned
|
|
34
|
+
* to the phone channel, or not pinned at all (any channel). */
|
|
35
|
+
export function isDialableConversationBrief(brief: Pick<ConversationBrief, 'channel'>): boolean {
|
|
36
|
+
return !brief.channel || brief.channel === 'phone'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The dialable numbers of a contact: its phone addresses, canonical (E.164)
|
|
40
|
+
* value first-served, deduplicated. */
|
|
41
|
+
export function contactPhoneNumbers(
|
|
42
|
+
contact: Pick<Contact, 'addresses'> | null | undefined,
|
|
43
|
+
): string[] {
|
|
44
|
+
const numbers: string[] = []
|
|
45
|
+
for (const address of contact?.addresses ?? []) {
|
|
46
|
+
if (address.kind !== 'phone' || !address.value) continue
|
|
47
|
+
if (!numbers.includes(address.value)) numbers.push(address.value)
|
|
48
|
+
}
|
|
49
|
+
return numbers
|
|
50
|
+
}
|
|
@@ -110,3 +110,65 @@ describe('DataTable selection', () => {
|
|
|
110
110
|
expect(onRowClick).not.toHaveBeenCalled()
|
|
111
111
|
})
|
|
112
112
|
})
|
|
113
|
+
|
|
114
|
+
describe('DataTable grouping', () => {
|
|
115
|
+
type GroupedRow = Row & { module: string }
|
|
116
|
+
|
|
117
|
+
const GROUPED: GroupedRow[] = [
|
|
118
|
+
{ id: 'g-1', name: 'Alpha', module: '' },
|
|
119
|
+
{ id: 'g-2', name: 'Beta', module: 'crm' },
|
|
120
|
+
{ id: 'g-3', name: 'Gamma', module: 'crm' },
|
|
121
|
+
{ id: 'g-4', name: 'Delta', module: 'billing' },
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
function groupedQuery(): InfiniteQueryLike<GroupedRow> {
|
|
125
|
+
return {
|
|
126
|
+
data: { pages: [{ data: GROUPED, meta: { items_total: GROUPED.length } }] },
|
|
127
|
+
isLoading: false,
|
|
128
|
+
isFetchingNextPage: false,
|
|
129
|
+
hasNextPage: false,
|
|
130
|
+
error: null,
|
|
131
|
+
fetchNextPage: vi.fn(),
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const groupedColumns = [{ id: 'name', accessorKey: 'name', header: 'Name', size: 160 }]
|
|
136
|
+
|
|
137
|
+
function renderGrouped() {
|
|
138
|
+
render(
|
|
139
|
+
<DataTable<GroupedRow>
|
|
140
|
+
query={groupedQuery()}
|
|
141
|
+
columns={groupedColumns}
|
|
142
|
+
ariaLabel="Records"
|
|
143
|
+
groupBy={(row) => (row.module ? { id: row.module, label: row.module } : null)}
|
|
144
|
+
/>,
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
it('emits one heading per group and none for ungrouped rows', () => {
|
|
149
|
+
renderGrouped()
|
|
150
|
+
|
|
151
|
+
expect(screen.getByText('crm')).toBeInTheDocument()
|
|
152
|
+
expect(screen.getByText('billing')).toBeInTheDocument()
|
|
153
|
+
// Every data row still renders alongside the headings.
|
|
154
|
+
for (const row of GROUPED) {
|
|
155
|
+
expect(screen.getByText(row.name)).toBeInTheDocument()
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('places each heading directly above its own rows', () => {
|
|
160
|
+
renderGrouped()
|
|
161
|
+
|
|
162
|
+
const text = document.body.textContent ?? ''
|
|
163
|
+
expect(text.indexOf('crm')).toBeLessThan(text.indexOf('Beta'))
|
|
164
|
+
expect(text.indexOf('Beta')).toBeLessThan(text.indexOf('billing'))
|
|
165
|
+
expect(text.indexOf('billing')).toBeLessThan(text.indexOf('Delta'))
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('renders no headings when groupBy is omitted', () => {
|
|
169
|
+
render(
|
|
170
|
+
<DataTable<GroupedRow> query={groupedQuery()} columns={groupedColumns} ariaLabel="Records" />,
|
|
171
|
+
)
|
|
172
|
+
expect(screen.queryByText('crm')).toBeNull()
|
|
173
|
+
})
|
|
174
|
+
})
|