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,86 @@
import type { ComponentProps, PropsWithChildren } from 'react'
import { JSDOM } from 'jsdom'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'

const validation = vi.hoisted(() => ({
current: { isChecking: false, error: 'Use lowercase letters', isValid: false } as {
isChecking: boolean
error: string | null
isValid: boolean
},
}))

vi.mock('@sim/emcn', () => ({
Input: (props: ComponentProps<'input'>) => <input {...props} />,
Label: (props: ComponentProps<'label'>) => (
<label htmlFor={props.htmlFor} className={props.className}>
{props.children}
</label>
),
cn: (...values: unknown[]) => values.filter(Boolean).join(' '),
Tooltip: {
Root: ({ children }: PropsWithChildren) => <>{children}</>,
Trigger: ({ children }: PropsWithChildren) => <>{children}</>,
Content: ({ children }: PropsWithChildren) => <>{children}</>,
},
}))
vi.mock('@sim/emcn/icons', () => ({ Check: () => null, TriangleAlert: () => null }))
vi.mock('@sim/logger', () => ({ createLogger: () => ({}) }))
vi.mock('@/components/ui', () => ({ GeneratedPasswordInput: () => null }))
vi.mock('@/lib/core/config/deployment-shape', () => ({ useDeploymentShape: () => ({}) }))
vi.mock('@/lib/core/utils/urls', () => ({
getBaseUrl: () => 'https://sim.ai',
getEmailDomain: () => 'sim.ai',
}))
vi.mock('@/lib/messaging/email/validation', () => ({ validateAllowlistEntry: () => true }))
vi.mock('@/lib/workflows/streaming/output-selector', () => ({
formatInternalOutputSelector: () => '',
}))
vi.mock(
'@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select',
() => ({
OutputSelect: () => null,
})
)
vi.mock('@/hooks/queries/chats', () => ({
useCreateChat: () => ({}),
useDeleteChat: () => ({}),
useRevealChatPassword: () => ({}),
useUpdateChat: () => ({}),
}))
vi.mock('@/hooks/use-permission-config', () => ({ usePermissionConfig: () => ({}) }))
vi.mock('./hooks', () => ({ useIdentifierValidation: () => validation.current }))

import { IdentifierInput } from './chat'

function renderIdentifier() {
return new JSDOM(renderToStaticMarkup(<IdentifierInput value='bad path' onChange={vi.fn()} />))
.window.document
}

describe('deploy URL field error', () => {
it('announces and associates the URL validation error with its input', () => {
validation.current = { isChecking: false, error: 'Use lowercase letters', isValid: false }
const document = renderIdentifier()
const input = document.querySelector<HTMLInputElement>('#chat-url')
const alert = document.querySelector<HTMLElement>('[role="alert"]')

expect(alert?.textContent).toBe('Use lowercase letters')
expect(alert?.className).toBe('mt-[6.5px] text-[var(--text-error)] text-caption')
expect(input?.getAttribute('aria-invalid')).toBe('true')
expect(input?.getAttribute('aria-describedby')).toBe(alert?.id)
expect(alert?.id).toBeTruthy()
expect(document.querySelector('label')?.htmlFor).toBe(input?.id)
})

it('omits the error relationship when the URL is valid', () => {
validation.current = { isChecking: false, error: null, isValid: true }
const document = renderIdentifier()
const input = document.querySelector<HTMLInputElement>('#chat-url')

expect(document.querySelector('[role="alert"]')).toBeNull()
expect(input?.getAttribute('aria-invalid')).toBe('false')
expect(input?.hasAttribute('aria-describedby')).toBe(false)
})
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { useEffect, useRef, useState } from 'react'
import { type ReactNode, useEffect, useId, useRef, useState } from 'react'
import {
ChipButtonGroup,
ChipButtonGroupItem,
Expand Down Expand Up @@ -49,6 +49,19 @@ const logger = createLogger('ChatDeploy')

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

interface DeployFieldErrorProps {
children: ReactNode
id?: string
}

function DeployFieldError({ children, id }: DeployFieldErrorProps) {
return (
<p id={id} role='alert' className='mt-[6.5px] text-[var(--text-error)] text-caption'>
{children}
</p>
)
}

interface ChatDeployProps {
workflowId: string
deploymentInfo: {
Expand Down Expand Up @@ -382,11 +395,7 @@ export function ChatDeploy({
className='w-full'
disablePortal
/>
{errors.outputBlocks && (
<p className='mt-[6.5px] text-[var(--text-error)] text-caption'>
{errors.outputBlocks}
</p>
)}
{errors.outputBlocks && <DeployFieldError>{errors.outputBlocks}</DeployFieldError>}
</div>

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

function IdentifierInput({
export function IdentifierInput({
value,
onChange,
originalIdentifier,
disabled = false,
onValidationChange,
isEditingExisting = false,
}: IdentifierInputProps) {
const errorId = useId()
const { isChecking, error, isValid } = useIdentifierValidation(
value,
originalIdentifier,
Expand Down Expand Up @@ -590,6 +600,8 @@ function IdentifierInput({
onChange={(e) => handleChange(e.target.value)}
required
disabled={disabled}
aria-invalid={Boolean(error)}
aria-describedby={error ? errorId : undefined}
className={cn(
'rounded-none border-0 bg-transparent pl-0 shadow-none disabled:bg-transparent disabled:opacity-100',
(isChecking || (isValid && value)) && 'pr-8'
Expand Down Expand Up @@ -617,7 +629,7 @@ function IdentifierInput({
)}
</div>
</div>
{error && <p className='mt-[6.5px] text-[var(--text-error)] text-caption'>{error}</p>}
{error && <DeployFieldError id={errorId}>{error}</DeployFieldError>}
<p className='mt-[6.5px] truncate text-[var(--text-secondary)] text-xs'>
{isEditingExisting && value ? (
<>
Expand Down Expand Up @@ -745,9 +757,7 @@ function AuthSelector({
}
/>
{canRevealPassword && revealPasswordMutation.isError && (
<p className='mt-[6.5px] text-[var(--text-error)] text-caption'>
Failed to load the current password
</p>
<DeployFieldError>Failed to load the current password</DeployFieldError>
)}
<p className='mt-[6.5px] text-[var(--text-secondary)] text-xs'>
{getPasswordHelperText(hasExistingPassword)}
Expand All @@ -772,7 +782,7 @@ function AuthSelector({
</div>
)}

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