@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.
@@ -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
+ })