@proteos/ui 0.42.1 → 0.42.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteos/ui",
3
- "version": "0.42.1",
3
+ "version": "0.42.3",
4
4
  "license": "FSL-1.1-Apache-2.0",
5
5
  "description": "Proteos React component library (React Aria Components + Tailwind v4).",
6
6
  "repository": {
@@ -55,7 +55,7 @@
55
55
  "tailwind-merge": "^3.5.0",
56
56
  "tailwind-variants": "^3.2.2",
57
57
  "tailwindcss": "^4.2.4",
58
- "@proteos/sdk": "0.42.1"
58
+ "@proteos/sdk": "0.42.3"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@biomejs/biome": "^2.4.13",
@@ -77,8 +77,8 @@
77
77
  "typescript": "^6.0.3",
78
78
  "vite": "^8.0.10",
79
79
  "vitest": "^3.2.4",
80
- "@proteos/biome-config": "0.0.0",
81
- "@proteos/tsconfig": "0.0.0"
80
+ "@proteos/tsconfig": "0.0.0",
81
+ "@proteos/biome-config": "0.0.0"
82
82
  },
83
83
  "scripts": {
84
84
  "lint": "biome check .",
@@ -0,0 +1,44 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+
3
+ import { ChannelIcon } from './channel-icon'
4
+
5
+ const meta: Meta<typeof ChannelIcon> = {
6
+ title: 'Components/ChannelIcon',
7
+ component: ChannelIcon,
8
+ tags: ['autodocs'],
9
+ parameters: { layout: 'padded' },
10
+ }
11
+ export default meta
12
+ type Story = StoryObj<typeof ChannelIcon>
13
+
14
+ const CHANNELS = [
15
+ 'email',
16
+ 'slack',
17
+ 'whatsapp',
18
+ 'linkedin',
19
+ 'telegram',
20
+ 'instagram',
21
+ 'messenger',
22
+ 'x',
23
+ 'meeting',
24
+ 'zoom-meeting',
25
+ 'google-meet',
26
+ 'teams-meeting',
27
+ 'webex-meeting',
28
+ 'adhoc',
29
+ 'carrier-pigeon',
30
+ ]
31
+
32
+ /* ===== Every channel, plus the unknown-channel fallback at the end ===== */
33
+ export const AllChannels: Story = {
34
+ render: () => (
35
+ <div className="flex flex-wrap gap-4">
36
+ {CHANNELS.map((channel) => (
37
+ <div key={channel} className="flex w-28 flex-col items-center gap-1.5 text-ink-3">
38
+ <ChannelIcon channel={channel} className="size-5 text-ink" />
39
+ <span className="font-mono text-[11px]">{channel}</span>
40
+ </div>
41
+ ))}
42
+ </div>
43
+ ),
44
+ }
@@ -0,0 +1,26 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { ChannelIcon, channelIcon } from './channel-icon'
5
+
6
+ describe('channelIcon', () => {
7
+ it('returns a distinct glyph per known channel', () => {
8
+ expect(channelIcon('email')).not.toBe(channelIcon('slack'))
9
+ })
10
+
11
+ it('falls back to the generic glyph for an unknown channel', () => {
12
+ expect(channelIcon('carrier-pigeon')).toBe(channelIcon('messenger'))
13
+ })
14
+ })
15
+
16
+ describe('ChannelIcon', () => {
17
+ it('is decorative by default', () => {
18
+ render(<ChannelIcon channel="email" />)
19
+ expect(screen.queryByRole('img')).toBeNull()
20
+ })
21
+
22
+ it('exposes an accessible name when labelled', () => {
23
+ render(<ChannelIcon channel="email" label="Email" />)
24
+ expect(screen.getByRole('img', { name: 'Email' })).toBeInTheDocument()
25
+ })
26
+ })
@@ -0,0 +1,67 @@
1
+ import {
2
+ AtSign,
3
+ AudioLines,
4
+ Camera,
5
+ Hash,
6
+ type LucideIcon,
7
+ Linkedin,
8
+ Mail,
9
+ MessageCircle,
10
+ MessagesSquare,
11
+ Mic,
12
+ Send,
13
+ Video,
14
+ } from 'lucide-react'
15
+
16
+ import { cn } from '../../lib/cn'
17
+
18
+ /* =========================================================================
19
+ Channel glyphs — the one lucide lookup for a conversation channel, shared
20
+ by every surface that shows where a message came from (hub sidebar,
21
+ compose picker, draft-review card, custom components). Channels the
22
+ platform gained but this map hasn't fall back to the generic conversation
23
+ glyph rather than rendering nothing.
24
+
25
+ Deliberately keyed by a loose `string`: `Message['channel']` widens over
26
+ time and callers routinely hold a raw slug from the wire.
27
+ ========================================================================= */
28
+
29
+ const CHANNEL_ICONS: Readonly<Record<string, LucideIcon>> = {
30
+ slack: Hash,
31
+ email: Mail,
32
+ whatsapp: MessageCircle,
33
+ linkedin: Linkedin,
34
+ telegram: Send,
35
+ instagram: Camera,
36
+ messenger: MessagesSquare,
37
+ x: AtSign,
38
+ meeting: Mic,
39
+ 'zoom-meeting': Video,
40
+ 'google-meet': Video,
41
+ 'teams-meeting': Video,
42
+ 'webex-meeting': Video,
43
+ adhoc: AudioLines,
44
+ }
45
+
46
+ /** The channel's glyph component. Unknown channels get the generic one. */
47
+ export function channelIcon(channel: string): LucideIcon {
48
+ return CHANNEL_ICONS[channel] ?? MessagesSquare
49
+ }
50
+
51
+ export interface ChannelIconProps {
52
+ channel: string
53
+ className?: string
54
+ /** Accessible name. Omitted (the default) renders the glyph aria-hidden —
55
+ * correct whenever the channel is already named in adjacent text. */
56
+ label?: string
57
+ }
58
+
59
+ /** Thin wrapper over {@link channelIcon} for the common inline case. */
60
+ export function ChannelIcon({ channel, className, label }: ChannelIconProps) {
61
+ const Icon = channelIcon(channel)
62
+ return label !== undefined ? (
63
+ <Icon className={cn('size-4', className)} role="img" aria-label={label} />
64
+ ) : (
65
+ <Icon className={cn('size-4', className)} aria-hidden />
66
+ )
67
+ }
@@ -0,0 +1,2 @@
1
+ export type { ChannelIconProps } from './channel-icon'
2
+ export { ChannelIcon, channelIcon } from './channel-icon'
@@ -0,0 +1,94 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import type { Conversation, Message } from '@proteos/sdk'
3
+
4
+ import type { DraftMessageClient } from '../draft-message-composer'
5
+ import { DraftMessageCard } from './draft-message-card'
6
+ import { DraftMessageReview } from './draft-message-review'
7
+
8
+ const meta: Meta<typeof DraftMessageCard> = {
9
+ title: 'Components/DraftMessageCard',
10
+ component: DraftMessageCard,
11
+ tags: ['autodocs'],
12
+ parameters: { layout: 'padded' },
13
+ }
14
+ export default meta
15
+ type Story = StoryObj<typeof DraftMessageCard>
16
+
17
+ /* ===== The three card states, side by side ===== */
18
+
19
+ export const AwaitingReview: Story = {
20
+ args: {
21
+ channel: 'email',
22
+ title: 'Re: Q3 freight tender — revised lane pricing',
23
+ recipients: ['Dana Ruiz', 'ops@northline.example'],
24
+ },
25
+ }
26
+
27
+ export const Sent: Story = {
28
+ args: { ...AwaitingReview.args, outcome: 'sent' } as Story['args'],
29
+ }
30
+
31
+ export const DeliveryFailed: Story = {
32
+ args: { ...AwaitingReview.args, outcome: 'failed' } as Story['args'],
33
+ }
34
+
35
+ export const ChatChannelNoSubject: Story = {
36
+ args: {
37
+ channel: 'slack',
38
+ title: 'Confirming the Tuesday pickup window at the Dallas yard',
39
+ },
40
+ }
41
+
42
+ /* ===== Connected: DraftMessageReview against a stubbed client ===== */
43
+
44
+ const draft = {
45
+ id: 'draft-1',
46
+ org_id: 'org-1',
47
+ conversation_id: 'conv-1',
48
+ connection_id: 'conn-1',
49
+ channel: 'email',
50
+ direction: 'outbound',
51
+ external_message_id: '',
52
+ sender: { name: 'Ava' },
53
+ recipients: [{ external_id: 'dana@northline.example', name: 'Dana Ruiz', role: 'to' }],
54
+ content: [{ type: 'text', text: 'Hi Dana — revised lane pricing attached.' }],
55
+ status: 'draft',
56
+ occurred_at: '2026-07-22T10:00:00Z',
57
+ metadata: {},
58
+ error: '',
59
+ created_at: '2026-07-22T10:00:00Z',
60
+ created_by: { type: 'agent', id: 'ava' },
61
+ updated_at: '2026-07-22T10:00:00Z',
62
+ updated_by: { type: 'agent', id: 'ava' },
63
+ } as Message
64
+
65
+ const stubClient: DraftMessageClient = {
66
+ getMessage: async () => draft,
67
+ getConversation: async () =>
68
+ ({ id: 'conv-1', subject: 'Q3 freight tender', status: 'draft', channel: 'email' }) as Conversation,
69
+ updateDraft: async () => draft,
70
+ sendDraft: async () => ({ ...draft, status: 'sent' }) as Message,
71
+ rejectDraft: async () => ({ ...draft, status: 'rejected' }) as Message,
72
+ searchRecipients: async () => [],
73
+ }
74
+
75
+ /** Default seam: Review opens the built-in modal around DraftMessageComposer. */
76
+ export const ReviewWithBuiltInModal: StoryObj<typeof DraftMessageReview> = {
77
+ render: () => <DraftMessageReview messageId="draft-1" client={stubClient} />,
78
+ }
79
+
80
+ /** Overridden seam: `onPreview` replaces the modal entirely — this is the shape
81
+ * a sandboxed custom component uses, delegating to the host via postMessage. */
82
+ export const ReviewWithHostedPreview: StoryObj<typeof DraftMessageReview> = {
83
+ render: () => (
84
+ <DraftMessageReview
85
+ messageId="draft-1"
86
+ client={stubClient}
87
+ previewLabel="Open in host"
88
+ onPreview={async () => {
89
+ window.alert('The host would open the review modal here (e.g. useComponentModal).')
90
+ return 'sent'
91
+ }}
92
+ />
93
+ ),
94
+ }
@@ -0,0 +1,43 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+
5
+ import { DraftMessageCard } from './draft-message-card'
6
+
7
+ describe('DraftMessageCard', () => {
8
+ it('renders the headline and the To line', () => {
9
+ render(
10
+ <DraftMessageCard channel="email" title="quarterly numbers" recipients={['Alice', 'Bob']} />,
11
+ )
12
+ expect(screen.getByText('quarterly numbers')).toBeInTheDocument()
13
+ expect(screen.getByText('To Alice, Bob')).toBeInTheDocument()
14
+ })
15
+
16
+ it('omits the To line when there are no recipients', () => {
17
+ render(<DraftMessageCard channel="slack" title="ping" recipients={[]} />)
18
+ expect(screen.queryByText(/^To /)).toBeNull()
19
+ })
20
+
21
+ it('fires onPreview from the action', async () => {
22
+ const onPreview = vi.fn()
23
+ render(<DraftMessageCard channel="email" title="draft" onPreview={onPreview} />)
24
+ await userEvent.click(screen.getByRole('button', { name: 'Review' }))
25
+ expect(onPreview).toHaveBeenCalledOnce()
26
+ })
27
+
28
+ it('replaces the action with a status label once decided', () => {
29
+ render(<DraftMessageCard channel="email" title="draft" outcome="rejected" />)
30
+ expect(screen.queryByRole('button')).toBeNull()
31
+ expect(screen.getByText('Rejected')).toBeInTheDocument()
32
+ })
33
+
34
+ it('spells out that a failure happened AFTER approval', () => {
35
+ render(<DraftMessageCard channel="email" title="draft" outcome="failed" />)
36
+ expect(screen.getByText('Approved, but delivery failed')).toBeInTheDocument()
37
+ })
38
+
39
+ it('disables the action while a preview is in flight', () => {
40
+ render(<DraftMessageCard channel="email" title="draft" isPreviewDisabled />)
41
+ expect(screen.getByRole('button', { name: 'Review' })).toBeDisabled()
42
+ })
43
+ })
@@ -0,0 +1,111 @@
1
+ import type { ReactNode } from 'react'
2
+ import { Button as AriaButton } from 'react-aria-components'
3
+ import { tv } from 'tailwind-variants'
4
+
5
+ import { cn } from '../../lib/cn'
6
+ import { ChannelIcon } from '../channel-icon'
7
+ import type { DraftReviewOutcome } from '../draft-message-composer'
8
+
9
+ /* =========================================================================
10
+ DraftMessageCard — the compact entry point to reviewing an agent-drafted
11
+ outbound message: channel glyph, eyebrow, headline (subject or first body
12
+ line), the To line, and one action. Pure chrome — it neither fetches nor
13
+ decides anything, so it can sit in a chat flow, a record page, a task list
14
+ or a custom component alike. DraftMessageReview is the connected wrapper;
15
+ reach for this one only when the host already holds the draft.
16
+
17
+ Once decided the action is replaced by a status label rather than removed:
18
+ the row keeps its shape, and the reader keeps the record of what happened.
19
+ ========================================================================= */
20
+
21
+ export const draftMessageCardStyles = tv({
22
+ slots: {
23
+ root: [
24
+ 'flex items-center gap-2.5 rounded-md border border-accent/40 px-3 py-2',
25
+ 'bg-[color-mix(in_oklab,var(--color-accent)_6%,var(--color-surface))]',
26
+ ],
27
+ icon: [
28
+ 'grid size-8 shrink-0 place-items-center rounded-full text-accent',
29
+ 'bg-[color-mix(in_oklab,var(--color-accent)_12%,transparent)]',
30
+ ],
31
+ body: 'min-w-0 flex-1',
32
+ eyebrow: 'font-mono text-[10px] uppercase tracking-[0.08em] text-accent',
33
+ title: 'truncate text-[13px] font-medium text-ink',
34
+ recipients: 'truncate text-[12px] text-ink-3',
35
+ action: [
36
+ 'shrink-0 cursor-pointer rounded-sm border border-accent/50 px-2.5 py-1',
37
+ 'font-mono text-[11px] text-accent outline-none',
38
+ 'hover:bg-[color-mix(in_oklab,var(--color-accent)_12%,transparent)]',
39
+ 'focus-visible:shadow-[var(--shadow-focus)]',
40
+ 'disabled:cursor-default disabled:opacity-50',
41
+ ],
42
+ outcome: 'shrink-0 font-mono text-[11px] text-ink-3',
43
+ },
44
+ })
45
+
46
+ const styles = draftMessageCardStyles()
47
+
48
+ /** Terminal-state wording. `failed` is deliberately explicit: the human DID
49
+ * approve the send, and hiding that behind a bare "Failed" reads as though
50
+ * the review itself failed. */
51
+ export const DRAFT_OUTCOME_LABEL: Record<DraftReviewOutcome, string> = {
52
+ sent: 'Sent',
53
+ rejected: 'Rejected',
54
+ failed: 'Approved, but delivery failed',
55
+ }
56
+
57
+ export interface DraftMessageCardProps {
58
+ /** Channel slug driving the glyph — see channelIcon. */
59
+ channel: string
60
+ /** Headline: the conversation subject, or the draft's first body line. */
61
+ title: string
62
+ /** Rendered as "To a, b". Empty or omitted hides the row. */
63
+ recipients?: string[]
64
+ /** Default 'Draft — awaiting review'. */
65
+ eyebrow?: ReactNode
66
+ /** Default 'Review'. */
67
+ previewLabel?: string
68
+ /** Terminal outcome. Set ⇒ the action is replaced by its status label. */
69
+ outcome?: DraftReviewOutcome | null
70
+ onPreview?: () => void
71
+ isPreviewDisabled?: boolean
72
+ className?: string
73
+ }
74
+
75
+ export function DraftMessageCard({
76
+ channel,
77
+ title,
78
+ recipients,
79
+ eyebrow = 'Draft — awaiting review',
80
+ previewLabel = 'Review',
81
+ outcome,
82
+ onPreview,
83
+ isPreviewDisabled = false,
84
+ className,
85
+ }: DraftMessageCardProps) {
86
+ return (
87
+ <div className={cn(styles.root(), className)}>
88
+ <span className={styles.icon()}>
89
+ <ChannelIcon channel={channel} />
90
+ </span>
91
+ <div className={styles.body()}>
92
+ <p className={styles.eyebrow()}>{eyebrow}</p>
93
+ <p className={styles.title()}>{title}</p>
94
+ {recipients && recipients.length > 0 ? (
95
+ <p className={styles.recipients()}>To {recipients.join(', ')}</p>
96
+ ) : null}
97
+ </div>
98
+ {outcome ? (
99
+ <span className={styles.outcome()}>{DRAFT_OUTCOME_LABEL[outcome]}</span>
100
+ ) : (
101
+ <AriaButton
102
+ onPress={onPreview ?? (() => {})}
103
+ isDisabled={isPreviewDisabled}
104
+ className={styles.action()}
105
+ >
106
+ {previewLabel}
107
+ </AriaButton>
108
+ )}
109
+ </div>
110
+ )
111
+ }
@@ -0,0 +1,170 @@
1
+ import type { Conversation, Message } from '@proteos/sdk'
2
+ import { render, screen, waitFor, within } from '@testing-library/react'
3
+ import userEvent from '@testing-library/user-event'
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
5
+
6
+ import {
7
+ __resetDraftMessageActions,
8
+ __resetDraftMessageOutcomes,
9
+ type DraftMessageClient,
10
+ publishDraftMessageOutcome,
11
+ } from '../draft-message-composer'
12
+ import { DraftMessageReview } from './draft-message-review'
13
+
14
+ function draftMessage(overrides: Partial<Message> = {}): Message {
15
+ return {
16
+ id: 'draft-1',
17
+ org_id: 'org-1',
18
+ conversation_id: 'conv-1',
19
+ connection_id: 'conn-1',
20
+ channel: 'email',
21
+ direction: 'outbound',
22
+ external_message_id: '',
23
+ sender: { name: 'Ava Agent' },
24
+ recipients: [{ external_id: 'a@x.io', name: 'Alice', role: 'to' }],
25
+ content: [{ type: 'text', text: 'agent draft body\nsecond line' }],
26
+ status: 'draft',
27
+ occurred_at: '2026-07-22T10:00:00Z',
28
+ metadata: {},
29
+ error: '',
30
+ created_at: '2026-07-22T10:00:00Z',
31
+ created_by: { type: 'agent', id: 'agent-1' },
32
+ updated_at: '2026-07-22T10:00:00Z',
33
+ updated_by: { type: 'agent', id: 'agent-1' },
34
+ ...overrides,
35
+ }
36
+ }
37
+
38
+ function draftConversation(overrides: Partial<Conversation> = {}): Conversation {
39
+ return { id: 'conv-1', subject: 'quarterly numbers', status: 'draft', channel: 'email', ...overrides } as Conversation
40
+ }
41
+
42
+ function mockClient(overrides: Partial<DraftMessageClient> = {}): DraftMessageClient {
43
+ return {
44
+ getMessage: vi.fn().mockResolvedValue(draftMessage()),
45
+ getConversation: vi.fn().mockResolvedValue(draftConversation()),
46
+ updateDraft: vi.fn().mockResolvedValue(draftMessage()),
47
+ sendDraft: vi.fn().mockResolvedValue(draftMessage({ status: 'sent' })),
48
+ rejectDraft: vi.fn().mockResolvedValue(draftMessage({ status: 'rejected' })),
49
+ searchRecipients: vi.fn().mockResolvedValue([]),
50
+ ...overrides,
51
+ }
52
+ }
53
+
54
+ describe('DraftMessageReview', () => {
55
+ beforeEach(() => {
56
+ __resetDraftMessageOutcomes()
57
+ __resetDraftMessageActions()
58
+ })
59
+
60
+ it('renders the card from the loaded draft, subject first', async () => {
61
+ render(<DraftMessageReview messageId="draft-1" client={mockClient()} />)
62
+ expect(await screen.findByText('quarterly numbers')).toBeInTheDocument()
63
+ expect(screen.getByText('To Alice')).toBeInTheDocument()
64
+ expect(screen.getByRole('button', { name: 'Review' })).toBeInTheDocument()
65
+ })
66
+
67
+ it('falls back to the first body line when the conversation has no subject', async () => {
68
+ const client = mockClient({
69
+ getConversation: vi.fn().mockResolvedValue(draftConversation({ subject: '' })),
70
+ })
71
+ render(<DraftMessageReview messageId="draft-1" client={client} />)
72
+ expect(await screen.findByText('agent draft body')).toBeInTheDocument()
73
+ })
74
+
75
+ it('settles immediately for a draft that was already decided elsewhere', async () => {
76
+ const onOutcome = vi.fn()
77
+ const client = mockClient({
78
+ getMessage: vi.fn().mockResolvedValue(draftMessage({ status: 'rejected' })),
79
+ })
80
+ render(<DraftMessageReview messageId="draft-1" client={client} onOutcome={onOutcome} />)
81
+
82
+ expect(await screen.findByText('Rejected')).toBeInTheDocument()
83
+ expect(screen.queryByRole('button', { name: 'Review' })).toBeNull()
84
+ expect(onOutcome).toHaveBeenCalledExactlyOnceWith('rejected', expect.objectContaining({ status: 'rejected' }))
85
+ // Terminal on load ⇒ no need for the conversation.
86
+ expect(client.getConversation).not.toHaveBeenCalled()
87
+ })
88
+
89
+ it('reports a load failure and renders nothing', async () => {
90
+ const onLoadError = vi.fn()
91
+ const client = mockClient({ getMessage: vi.fn().mockRejectedValue(new Error('boom')) })
92
+ const { container } = render(
93
+ <DraftMessageReview messageId="draft-1" client={client} onLoadError={onLoadError} />,
94
+ )
95
+ await waitFor(() => expect(onLoadError).toHaveBeenCalledOnce())
96
+ expect(onLoadError.mock.calls[0]?.[0]).toBeInstanceOf(Error)
97
+ expect(container).toBeEmptyDOMElement()
98
+ })
99
+
100
+ it('opens the built-in composer modal when no override is given', async () => {
101
+ render(<DraftMessageReview messageId="draft-1" client={mockClient()} />)
102
+ await userEvent.click(await screen.findByRole('button', { name: 'Review' }))
103
+
104
+ const dialog = await screen.findByRole('dialog', { name: 'Review draft' })
105
+ // The composer itself is mounted inside, loaded and editable.
106
+ expect(await within(dialog).findByText('Draft · awaiting review')).toBeInTheDocument()
107
+ expect(within(dialog).getByRole('button', { name: 'Reject' })).toBeInTheDocument()
108
+ })
109
+
110
+ it('settles the card when the built-in composer rejects the draft', async () => {
111
+ const onOutcome = vi.fn()
112
+ render(<DraftMessageReview messageId="draft-1" client={mockClient()} onOutcome={onOutcome} />)
113
+ await userEvent.click(await screen.findByRole('button', { name: 'Review' }))
114
+
115
+ // Two-step confirm inside the composer.
116
+ await userEvent.click(await screen.findByRole('button', { name: 'Reject' }))
117
+ await userEvent.click(await screen.findByRole('button', { name: /confirm reject/i }))
118
+
119
+ await waitFor(() => expect(onOutcome).toHaveBeenCalledExactlyOnceWith('rejected', expect.anything()))
120
+ expect(await screen.findByText('Rejected')).toBeInTheDocument()
121
+ })
122
+
123
+ it('hands the whole interaction to onPreview and never renders its own modal', async () => {
124
+ const onPreview = vi.fn().mockResolvedValue('sent')
125
+ const onOutcome = vi.fn()
126
+ render(
127
+ <DraftMessageReview
128
+ messageId="draft-1"
129
+ client={mockClient()}
130
+ onPreview={onPreview}
131
+ onOutcome={onOutcome}
132
+ />,
133
+ )
134
+ await userEvent.click(await screen.findByRole('button', { name: 'Review' }))
135
+
136
+ expect(onPreview).toHaveBeenCalledExactlyOnceWith('draft-1')
137
+ expect(screen.queryByRole('dialog')).toBeNull()
138
+ await waitFor(() => expect(onOutcome).toHaveBeenCalledExactlyOnceWith('sent', undefined))
139
+ expect(await screen.findByText('Sent')).toBeInTheDocument()
140
+ })
141
+
142
+ it('leaves the card reviewable when onPreview resolves null (dismissed)', async () => {
143
+ const onPreview = vi.fn().mockResolvedValue(null)
144
+ const onOutcome = vi.fn()
145
+ render(
146
+ <DraftMessageReview
147
+ messageId="draft-1"
148
+ client={mockClient()}
149
+ onPreview={onPreview}
150
+ onOutcome={onOutcome}
151
+ />,
152
+ )
153
+ await userEvent.click(await screen.findByRole('button', { name: 'Review' }))
154
+
155
+ await waitFor(() => expect(onPreview).toHaveBeenCalledOnce())
156
+ expect(onOutcome).not.toHaveBeenCalled()
157
+ expect(await screen.findByRole('button', { name: 'Review' })).toBeEnabled()
158
+ })
159
+
160
+ it('settles from a composer elsewhere in the realm, once', async () => {
161
+ const onOutcome = vi.fn()
162
+ render(<DraftMessageReview messageId="draft-1" client={mockClient()} onOutcome={onOutcome} />)
163
+ await screen.findByRole('button', { name: 'Review' })
164
+
165
+ publishDraftMessageOutcome('draft-1', { type: 'sent', message: draftMessage({ status: 'sent' }) })
166
+
167
+ expect(await screen.findByText('Sent')).toBeInTheDocument()
168
+ expect(onOutcome).toHaveBeenCalledOnce()
169
+ })
170
+ })