Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* @vitest-environment jsdom
*/
import { act, type ReactNode } from 'react'
import { QueryClient, QueryClientProvider, useMutation } from '@tanstack/react-query'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({ enabled: false, toggle: vi.fn() }))
vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }) }))
vi.mock('@/hooks/queries/inbox', () => ({
useInboxConfig: () => ({ data: { enabled: mocks.enabled, address: 'inbox@example.com' } }),
useToggleInbox: () => useMutation({ mutationFn: mocks.toggle }),
}))
vi.mock('@sim/emcn', () => ({
Label: ({ children }: { children: ReactNode }) => <span>{children}</span>,
ChipSwitch: ({ onChange }: { onChange: (value: string) => void }) => (
<>
<button type='button' onClick={() => onChange('enabled')}>
On
</button>
<button type='button' onClick={() => onChange('disabled')}>
Off
</button>
</>
),
ChipModal: ({ open, children }: { open: boolean; children: ReactNode }) =>
open ? <section role='dialog'>{children}</section> : null,
ChipModalHeader: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
ChipModalBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
ChipModalField: () => null,
ChipModalError: ({ children }: { children: ReactNode }) =>
children ? <p role='alert'>{children}</p> : null,
ChipModalFooter: ({
onCancel,
primaryAction,
}: {
onCancel: () => void
primaryAction: { label: string; onClick: () => void }
}) => (
<>
<button type='button' onClick={onCancel}>
Cancel
</button>
<button type='button' onClick={primaryAction.onClick}>
{primaryAction.label}
</button>
</>
),
ChipConfirmModal: ({
open,
children,
onOpenChange,
confirm,
}: {
open: boolean
children: ReactNode
onOpenChange: (open: boolean) => void
confirm: { label: string; onClick: () => void }
}) =>
open ? (
<section role='dialog'>
{children}
<button type='button' onClick={() => onOpenChange(false)}>
Cancel
</button>
<button type='button' onClick={confirm.onClick}>
{confirm.label}
</button>
</section>
) : null,
}))

import { InboxEnableToggle } from '@/app/workspace/[workspaceId]/settings/components/inbox/components/inbox-enable-toggle/inbox-enable-toggle'

let container: HTMLDivElement
let root: Root
let client: QueryClient
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
mocks.enabled = false
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
client = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
})
afterEach(() => {
act(() => root.unmount())
client.clear()
container.remove()
vi.useRealTimers()
})

function render() {
act(() =>
root.render(
<QueryClientProvider client={client}>
<InboxEnableToggle />
</QueryClientProvider>
)
)
}
async function click(label: string) {
const button = Array.from(container.querySelectorAll('button')).find(
(button) => button.textContent === label
)
expect(button).toBeDefined()
await act(async () => {
button?.click()
await vi.runAllTimersAsync()
})
}

describe('inbox setup error visibility', () => {
it.each([
{ enabled: false, toggle: 'On', submit: 'Enable' },
{ enabled: true, toggle: 'Off', submit: 'Disable inbox' },
])(
'keeps the dialog open and displays a failed $submit request',
async ({ enabled, toggle, submit }) => {
mocks.enabled = enabled
mocks.toggle.mockRejectedValueOnce(new Error('Email service unavailable'))
render()
await click(toggle)
await click(submit)
expect(container.querySelector('[role="alert"]')?.textContent).toBe(
'Email service unavailable'
)
expect(container.querySelector('[role="dialog"]')).not.toBeNull()
await click('Cancel')
await click(toggle)
expect(container.querySelector('[role="alert"]')).toBeNull()
}
)

it('clears the failure and closes after a successful retry', async () => {
mocks.toggle
.mockRejectedValueOnce(new Error('Email service unavailable'))
.mockResolvedValueOnce({ enabled: true })
render()
await click('On')
await click('Enable')
expect(container.querySelector('[role="alert"]')).not.toBeNull()
await click('Enable')
expect(container.querySelector('[role="dialog"]')).toBeNull()
expect(mocks.toggle).toHaveBeenCalledTimes(2)
})
})
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
'use client'

import { useCallback, useState } from 'react'
import { useState } from 'react'
import {
ChipConfirmModal,
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
ChipSwitch,
Label,
Switch,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { useParams } from 'next/navigation'
import { useInboxConfig, useToggleInbox } from '@/hooks/queries/inbox'

const logger = createLogger('InboxEnableToggle')
const INBOX_OPTIONS = [
{ value: 'enabled', label: 'On' },
{ value: 'disabled', label: 'Off' },
] as const

export function InboxEnableToggle() {
const params = useParams()
Expand All @@ -28,56 +32,68 @@ export function InboxEnableToggle() {
const [isDisableOpen, setIsDisableOpen] = useState(false)
const [enableUsername, setEnableUsername] = useState('')

const handleToggle = useCallback(async (checked: boolean) => {
function handleToggle(checked: boolean) {
toggleInbox.reset()
if (checked) {
setIsEnableOpen(true)
return
} else {
setIsDisableOpen(true)
}
setIsDisableOpen(true)
}, [])
}

const handleDisable = useCallback(async () => {
try {
await toggleInbox.mutateAsync({ workspaceId, enabled: false })
setIsDisableOpen(false)
} catch (error) {
logger.error('Failed to disable inbox', { error })
}
}, [workspaceId, toggleInbox.mutateAsync])
function handleEnableOpenChange(open: boolean) {
if (!toggleInbox.isPending) setIsEnableOpen(open)
}

const handleEnable = useCallback(async () => {
try {
await toggleInbox.mutateAsync({
workspaceId,
enabled: true,
username: enableUsername.trim() || undefined,
})
setIsEnableOpen(false)
setEnableUsername('')
} catch (error) {
logger.error('Failed to enable inbox', { error })
}
}, [workspaceId, enableUsername, toggleInbox.mutateAsync])
function handleDisable() {
toggleInbox.mutate(
{ workspaceId, enabled: false },
{ onSuccess: () => setIsDisableOpen(false) }
)
}

function handleEnable() {
toggleInbox.mutate(
{ workspaceId, enabled: true, username: enableUsername.trim() || undefined },
{
onSuccess: () => {
setIsEnableOpen(false)
setEnableUsername('')
},
}
)
}

const error = toggleInbox.error
? getErrorMessage(toggleInbox.error, 'Failed to update inbox')
: null

return (
<>
<div className='flex items-center justify-between'>
<div className='flex flex-col gap-1'>
<Label htmlFor='inbox-enabled'>Enable email inbox</Label>
<Label>Enable email inbox</Label>
<p className='text-[var(--text-muted)] text-caption'>
Allow this workspace to receive tasks via email
</p>
</div>
<Switch
id='inbox-enabled'
checked={config?.enabled ?? false}
onCheckedChange={handleToggle}
<ChipSwitch
aria-label='Enable email inbox'
options={INBOX_OPTIONS}
value={config?.enabled ? 'enabled' : 'disabled'}
onChange={(value) => handleToggle(value === 'enabled')}
disabled={toggleInbox.isPending}
/>
</div>

<ChipModal open={isEnableOpen} onOpenChange={setIsEnableOpen} srTitle='Enable email inbox'>
<ChipModalHeader onClose={() => setIsEnableOpen(false)}>Enable email inbox</ChipModalHeader>
<ChipModal
open={isEnableOpen}
onOpenChange={handleEnableOpenChange}
srTitle='Enable email inbox'
>
<ChipModalHeader onClose={() => handleEnableOpenChange(false)}>
Enable email inbox
</ChipModalHeader>
<ChipModalBody>
<p className='px-2 text-[var(--text-secondary)] text-sm'>
An email address will be created for this workspace. Anyone in the allowed senders list
Expand All @@ -93,11 +109,13 @@ export function InboxEnableToggle() {
<p className='px-2 text-[var(--text-muted)] text-sm'>
Leave blank for an auto-generated address.
</p>
<ChipModalError>{error}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={() => setIsEnableOpen(false)}
onCancel={() => handleEnableOpenChange(false)}
cancelDisabled={toggleInbox.isPending}
primaryAction={{
label: 'Enable',
label: toggleInbox.isPending ? 'Enabling...' : 'Enable',
onClick: handleEnable,
disabled: toggleInbox.isPending,
}}
Expand Down Expand Up @@ -125,6 +143,7 @@ export function InboxEnableToggle() {
<p className='px-2 text-[var(--text-secondary)] text-sm'>
Your existing conversations and task history will be preserved.
</p>
<ChipModalError>{error}</ChipModalError>
</ChipConfirmModal>
</>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,11 @@ export function InboxSettingsTab() {
</div>
</div>
)}
{updateSecretPolicy.error && (
<p role='alert' className='text-[var(--text-error)] text-caption'>
{getErrorMessage(updateSecretPolicy.error, 'Failed to update secret access')}
</p>
)}
</div>
</SettingsSection>
</div>
Expand Down
Loading
Loading