Skip to content

Commit bb0c8f8

Browse files
BillLeoutsakosvl346Bill Leoutsakos
andauthored
Share deploy chat field error treatment (#8255)
* refactor(ui): share deploy chat field error treatment * refactor(ui): name deploy error props --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local>
1 parent be663f4 commit bb0c8f8

2 files changed

Lines changed: 108 additions & 12 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import type { ComponentProps, PropsWithChildren } from 'react'
2+
import { JSDOM } from 'jsdom'
3+
import { renderToStaticMarkup } from 'react-dom/server'
4+
import { describe, expect, it, vi } from 'vitest'
5+
6+
const validation = vi.hoisted(() => ({
7+
current: { isChecking: false, error: 'Use lowercase letters', isValid: false } as {
8+
isChecking: boolean
9+
error: string | null
10+
isValid: boolean
11+
},
12+
}))
13+
14+
vi.mock('@sim/emcn', () => ({
15+
Input: (props: ComponentProps<'input'>) => <input {...props} />,
16+
Label: (props: ComponentProps<'label'>) => (
17+
<label htmlFor={props.htmlFor} className={props.className}>
18+
{props.children}
19+
</label>
20+
),
21+
cn: (...values: unknown[]) => values.filter(Boolean).join(' '),
22+
Tooltip: {
23+
Root: ({ children }: PropsWithChildren) => <>{children}</>,
24+
Trigger: ({ children }: PropsWithChildren) => <>{children}</>,
25+
Content: ({ children }: PropsWithChildren) => <>{children}</>,
26+
},
27+
}))
28+
vi.mock('@sim/emcn/icons', () => ({ Check: () => null, TriangleAlert: () => null }))
29+
vi.mock('@sim/logger', () => ({ createLogger: () => ({}) }))
30+
vi.mock('@/components/ui', () => ({ GeneratedPasswordInput: () => null }))
31+
vi.mock('@/lib/core/config/deployment-shape', () => ({ useDeploymentShape: () => ({}) }))
32+
vi.mock('@/lib/core/utils/urls', () => ({
33+
getBaseUrl: () => 'https://sim.ai',
34+
getEmailDomain: () => 'sim.ai',
35+
}))
36+
vi.mock('@/lib/messaging/email/validation', () => ({ validateAllowlistEntry: () => true }))
37+
vi.mock('@/lib/workflows/streaming/output-selector', () => ({
38+
formatInternalOutputSelector: () => '',
39+
}))
40+
vi.mock(
41+
'@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select',
42+
() => ({
43+
OutputSelect: () => null,
44+
})
45+
)
46+
vi.mock('@/hooks/queries/chats', () => ({
47+
useCreateChat: () => ({}),
48+
useDeleteChat: () => ({}),
49+
useRevealChatPassword: () => ({}),
50+
useUpdateChat: () => ({}),
51+
}))
52+
vi.mock('@/hooks/use-permission-config', () => ({ usePermissionConfig: () => ({}) }))
53+
vi.mock('./hooks', () => ({ useIdentifierValidation: () => validation.current }))
54+
55+
import { IdentifierInput } from './chat'
56+
57+
function renderIdentifier() {
58+
return new JSDOM(renderToStaticMarkup(<IdentifierInput value='bad path' onChange={vi.fn()} />))
59+
.window.document
60+
}
61+
62+
describe('deploy URL field error', () => {
63+
it('announces and associates the URL validation error with its input', () => {
64+
validation.current = { isChecking: false, error: 'Use lowercase letters', isValid: false }
65+
const document = renderIdentifier()
66+
const input = document.querySelector<HTMLInputElement>('#chat-url')
67+
const alert = document.querySelector<HTMLElement>('[role="alert"]')
68+
69+
expect(alert?.textContent).toBe('Use lowercase letters')
70+
expect(alert?.className).toBe('mt-[6.5px] text-[var(--text-error)] text-caption')
71+
expect(input?.getAttribute('aria-invalid')).toBe('true')
72+
expect(input?.getAttribute('aria-describedby')).toBe(alert?.id)
73+
expect(alert?.id).toBeTruthy()
74+
expect(document.querySelector('label')?.htmlFor).toBe(input?.id)
75+
})
76+
77+
it('omits the error relationship when the URL is valid', () => {
78+
validation.current = { isChecking: false, error: null, isValid: true }
79+
const document = renderIdentifier()
80+
const input = document.querySelector<HTMLInputElement>('#chat-url')
81+
82+
expect(document.querySelector('[role="alert"]')).toBeNull()
83+
expect(input?.getAttribute('aria-invalid')).toBe('false')
84+
expect(input?.hasAttribute('aria-describedby')).toBe(false)
85+
})
86+
})

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx‎

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useEffect, useRef, useState } from 'react'
3+
import { type ReactNode, useEffect, useId, useRef, useState } from 'react'
44
import {
55
ChipButtonGroup,
66
ChipButtonGroupItem,
@@ -49,6 +49,19 @@ const logger = createLogger('ChatDeploy')
4949

5050
const IDENTIFIER_PATTERN = /^[a-z0-9-]+$/
5151

52+
interface DeployFieldErrorProps {
53+
children: ReactNode
54+
id?: string
55+
}
56+
57+
function DeployFieldError({ children, id }: DeployFieldErrorProps) {
58+
return (
59+
<p id={id} role='alert' className='mt-[6.5px] text-[var(--text-error)] text-caption'>
60+
{children}
61+
</p>
62+
)
63+
}
64+
5265
interface ChatDeployProps {
5366
workflowId: string
5467
deploymentInfo: {
@@ -382,11 +395,7 @@ export function ChatDeploy({
382395
className='w-full'
383396
disablePortal
384397
/>
385-
{errors.outputBlocks && (
386-
<p className='mt-[6.5px] text-[var(--text-error)] text-caption'>
387-
{errors.outputBlocks}
388-
</p>
389-
)}
398+
{errors.outputBlocks && <DeployFieldError>{errors.outputBlocks}</DeployFieldError>}
390399
</div>
391400

392401
<div className='flex items-center justify-between gap-3 px-2'>
@@ -539,14 +548,15 @@ const getDomainPrefix = (() => {
539548
return () => prefix
540549
})()
541550

542-
function IdentifierInput({
551+
export function IdentifierInput({
543552
value,
544553
onChange,
545554
originalIdentifier,
546555
disabled = false,
547556
onValidationChange,
548557
isEditingExisting = false,
549558
}: IdentifierInputProps) {
559+
const errorId = useId()
550560
const { isChecking, error, isValid } = useIdentifierValidation(
551561
value,
552562
originalIdentifier,
@@ -590,6 +600,8 @@ function IdentifierInput({
590600
onChange={(e) => handleChange(e.target.value)}
591601
required
592602
disabled={disabled}
603+
aria-invalid={Boolean(error)}
604+
aria-describedby={error ? errorId : undefined}
593605
className={cn(
594606
'rounded-none border-0 bg-transparent pl-0 shadow-none disabled:bg-transparent disabled:opacity-100',
595607
(isChecking || (isValid && value)) && 'pr-8'
@@ -617,7 +629,7 @@ function IdentifierInput({
617629
)}
618630
</div>
619631
</div>
620-
{error && <p className='mt-[6.5px] text-[var(--text-error)] text-caption'>{error}</p>}
632+
{error && <DeployFieldError id={errorId}>{error}</DeployFieldError>}
621633
<p className='mt-[6.5px] truncate text-[var(--text-secondary)] text-xs'>
622634
{isEditingExisting && value ? (
623635
<>
@@ -745,9 +757,7 @@ function AuthSelector({
745757
}
746758
/>
747759
{canRevealPassword && revealPasswordMutation.isError && (
748-
<p className='mt-[6.5px] text-[var(--text-error)] text-caption'>
749-
Failed to load the current password
750-
</p>
760+
<DeployFieldError>Failed to load the current password</DeployFieldError>
751761
)}
752762
<p className='mt-[6.5px] text-[var(--text-secondary)] text-xs'>
753763
{getPasswordHelperText(hasExistingPassword)}
@@ -772,7 +782,7 @@ function AuthSelector({
772782
</div>
773783
)}
774784

775-
{error && <p className='mt-[6.5px] text-[var(--text-error)] text-caption'>{error}</p>}
785+
{error && <DeployFieldError>{error}</DeployFieldError>}
776786
</div>
777787
)
778788
}

0 commit comments

Comments
 (0)