Merge remote-tracking branch 'origin/main' into feat/model-plugins-implementing

This commit is contained in:
yyh
2026-03-11 14:26:14 +08:00
23 changed files with 185 additions and 375 deletions

View File

@ -1,308 +1,114 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import Avatar from '../index'
import { render, screen } from '@testing-library/react'
import { Avatar } from '../index'
describe('Avatar', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests - verify component renders correctly in different states
describe('Rendering', () => {
it('should render img element with correct alt and src when avatar URL is provided', () => {
const avatarUrl = 'https://example.com/avatar.jpg'
const props = { name: 'John Doe', avatar: avatarUrl }
render(<Avatar {...props} />)
it('should render img element when avatar URL is provided', () => {
render(<Avatar name="John Doe" avatar="https://example.com/avatar.jpg" />)
const img = screen.getByRole('img', { name: 'John Doe' })
expect(img).toBeInTheDocument()
expect(img).toHaveAttribute('src', avatarUrl)
expect(img).toHaveAttribute('src', 'https://example.com/avatar.jpg')
})
it('should render fallback div with uppercase initial when avatar is null', () => {
const props = { name: 'alice', avatar: null }
render(<Avatar {...props} />)
it('should render fallback with uppercase initial when avatar is null', () => {
render(<Avatar name="alice" avatar={null} />)
expect(screen.queryByRole('img')).not.toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
})
})
// Props tests - verify all props are applied correctly
describe('Props', () => {
describe('size prop', () => {
it.each([
{ size: undefined, expected: '30px', label: 'default (30px)' },
{ size: 50, expected: '50px', label: 'custom (50px)' },
])('should apply $label size to img element', ({ size, expected }) => {
const props = { name: 'Test', avatar: 'https://example.com/avatar.jpg', size }
it('should render both image and fallback when avatar is provided', () => {
render(<Avatar name="John" avatar="https://example.com/avatar.jpg" />)
render(<Avatar {...props} />)
expect(screen.getByRole('img')).toHaveStyle({
width: expected,
height: expected,
fontSize: expected,
lineHeight: expected,
})
})
it('should apply size to fallback div when avatar is null', () => {
const props = { name: 'Test', avatar: null, size: 40 }
render(<Avatar {...props} />)
const textElement = screen.getByText('T')
const outerDiv = textElement.parentElement as HTMLElement
expect(outerDiv).toHaveStyle({ width: '40px', height: '40px' })
})
})
describe('className prop', () => {
it('should merge className with default avatar classes on img', () => {
const props = {
name: 'Test',
avatar: 'https://example.com/avatar.jpg',
className: 'custom-class',
}
render(<Avatar {...props} />)
const img = screen.getByRole('img')
expect(img).toHaveClass('custom-class')
expect(img).toHaveClass('shrink-0', 'flex', 'items-center', 'rounded-full', 'bg-primary-600')
})
it('should merge className with default avatar classes on fallback div', () => {
const props = {
name: 'Test',
avatar: null,
className: 'my-custom-class',
}
render(<Avatar {...props} />)
const textElement = screen.getByText('T')
const outerDiv = textElement.parentElement as HTMLElement
expect(outerDiv).toHaveClass('my-custom-class')
expect(outerDiv).toHaveClass('shrink-0', 'flex', 'items-center', 'rounded-full', 'bg-primary-600')
})
})
describe('textClassName prop', () => {
it('should apply textClassName to the initial text element', () => {
const props = {
name: 'Test',
avatar: null,
textClassName: 'custom-text-class',
}
render(<Avatar {...props} />)
const textElement = screen.getByText('T')
expect(textElement).toHaveClass('custom-text-class')
expect(textElement).toHaveClass('scale-[0.4]', 'text-center', 'text-white')
})
})
})
// State Management tests - verify useState and useEffect behavior
describe('State Management', () => {
it('should switch to fallback when image fails to load', async () => {
const props = { name: 'John', avatar: 'https://example.com/broken.jpg' }
render(<Avatar {...props} />)
const img = screen.getByRole('img')
fireEvent.error(img)
await waitFor(() => {
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
expect(screen.getByText('J')).toBeInTheDocument()
})
it('should reset error state when avatar URL changes', async () => {
const initialProps = { name: 'John', avatar: 'https://example.com/broken.jpg' }
const { rerender } = render(<Avatar {...initialProps} />)
const img = screen.getByRole('img')
// First, trigger error
fireEvent.error(img)
await waitFor(() => {
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
expect(screen.getByText('J')).toBeInTheDocument()
rerender(<Avatar name="John" avatar="https://example.com/new-avatar.jpg" />)
await waitFor(() => {
expect(screen.getByRole('img')).toBeInTheDocument()
})
expect(screen.queryByText('J')).not.toBeInTheDocument()
})
it('should not reset error state if avatar becomes null', async () => {
const initialProps = { name: 'John', avatar: 'https://example.com/broken.jpg' }
const { rerender } = render(<Avatar {...initialProps} />)
// Trigger error
fireEvent.error(screen.getByRole('img'))
await waitFor(() => {
expect(screen.getByText('J')).toBeInTheDocument()
})
rerender(<Avatar name="John" avatar={null} />)
await waitFor(() => {
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
expect(screen.getByRole('img')).toBeInTheDocument()
expect(screen.getByText('J')).toBeInTheDocument()
})
})
// Event Handlers tests - verify onError callback behavior
describe('Event Handlers', () => {
it('should call onError with true when image fails to load', () => {
const onErrorMock = vi.fn()
const props = {
name: 'John',
avatar: 'https://example.com/broken.jpg',
onError: onErrorMock,
}
render(<Avatar {...props} />)
describe('Size variants', () => {
it.each([
{ size: 'xxs' as const, expectedClass: 'size-4' },
{ size: 'xs' as const, expectedClass: 'size-5' },
{ size: 'sm' as const, expectedClass: 'size-6' },
{ size: 'md' as const, expectedClass: 'size-8' },
{ size: 'lg' as const, expectedClass: 'size-9' },
{ size: 'xl' as const, expectedClass: 'size-10' },
{ size: '2xl' as const, expectedClass: 'size-12' },
{ size: '3xl' as const, expectedClass: 'size-16' },
])('should apply $expectedClass for size="$size"', ({ size, expectedClass }) => {
const { container } = render(<Avatar name="Test" avatar={null} size={size} />)
fireEvent.error(screen.getByRole('img'))
expect(onErrorMock).toHaveBeenCalledTimes(1)
expect(onErrorMock).toHaveBeenCalledWith(true)
const root = container.firstElementChild as HTMLElement
expect(root).toHaveClass(expectedClass)
})
it('should call onError with false when image loads successfully', () => {
const onErrorMock = vi.fn()
const props = {
name: 'John',
avatar: 'https://example.com/avatar.jpg',
onError: onErrorMock,
}
render(<Avatar {...props} />)
it('should default to md size when size is not specified', () => {
const { container } = render(<Avatar name="Test" avatar={null} />)
fireEvent.load(screen.getByRole('img'))
expect(onErrorMock).toHaveBeenCalledTimes(1)
expect(onErrorMock).toHaveBeenCalledWith(false)
})
it('should not throw when onError is not provided', async () => {
const props = { name: 'John', avatar: 'https://example.com/broken.jpg' }
render(<Avatar {...props} />)
expect(() => fireEvent.error(screen.getByRole('img'))).not.toThrow()
await waitFor(() => {
expect(screen.getByText('J')).toBeInTheDocument()
})
const root = container.firstElementChild as HTMLElement
expect(root).toHaveClass('size-8')
})
})
describe('className prop', () => {
it('should merge className with avatar variant classes on root', () => {
const { container } = render(
<Avatar name="Test" avatar={null} className="custom-class" />,
)
const root = container.firstElementChild as HTMLElement
expect(root).toHaveClass('custom-class')
expect(root).toHaveClass('rounded-full', 'bg-primary-600')
})
})
// Edge Cases tests - verify handling of unusual inputs
describe('Edge Cases', () => {
it('should handle empty string name gracefully', () => {
const props = { name: '', avatar: null }
const { container } = render(<Avatar name="" avatar={null} />)
const { container } = render(<Avatar {...props} />)
// Note: Using querySelector here because empty name produces no visible text,
// making semantic queries (getByRole, getByText) impossible
const textElement = container.querySelector('.text-white') as HTMLElement
expect(textElement).toBeInTheDocument()
expect(textElement.textContent).toBe('')
const fallback = container.querySelector('.text-white') as HTMLElement
expect(fallback).toBeInTheDocument()
expect(fallback.textContent).toBe('')
})
it.each([
{ name: '中文名', expected: '中', label: 'Chinese characters' },
{ name: '123User', expected: '1', label: 'number' },
])('should display first character when name starts with $label', ({ name, expected }) => {
const props = { name, avatar: null }
render(<Avatar {...props} />)
render(<Avatar name={name} avatar={null} />)
expect(screen.getByText(expected)).toBeInTheDocument()
})
it('should handle empty string avatar as falsy value', () => {
const props = { name: 'Test', avatar: '' as string | null }
render(<Avatar {...props} />)
render(<Avatar name="Test" avatar={'' as string | null} />)
expect(screen.queryByRole('img')).not.toBeInTheDocument()
expect(screen.getByText('T')).toBeInTheDocument()
})
it('should handle undefined className and textClassName', () => {
const props = { name: 'Test', avatar: null }
render(<Avatar {...props} />)
const textElement = screen.getByText('T')
const outerDiv = textElement.parentElement as HTMLElement
expect(outerDiv).toHaveClass('shrink-0', 'flex', 'items-center', 'rounded-full', 'bg-primary-600')
})
it.each([
{ size: 0, expected: '0px', label: 'zero' },
{ size: 1000, expected: '1000px', label: 'very large' },
])('should handle $label size value', ({ size, expected }) => {
const props = { name: 'Test', avatar: null, size }
render(<Avatar {...props} />)
const textElement = screen.getByText('T')
const outerDiv = textElement.parentElement as HTMLElement
expect(outerDiv).toHaveStyle({ width: expected, height: expected })
})
})
// Combined props tests - verify props work together correctly
describe('Combined Props', () => {
it('should apply all props correctly when used together', () => {
const onErrorMock = vi.fn()
const props = {
name: 'Test User',
avatar: 'https://example.com/avatar.jpg',
size: 64,
className: 'custom-avatar',
onError: onErrorMock,
}
describe('onLoadingStatusChange', () => {
it('should render image when avatar and onLoadingStatusChange are provided', () => {
render(
<Avatar
name="John"
avatar="https://example.com/avatar.jpg"
onLoadingStatusChange={vi.fn()}
/>,
)
render(<Avatar {...props} />)
const img = screen.getByRole('img')
expect(img).toHaveAttribute('alt', 'Test User')
expect(img).toHaveAttribute('src', 'https://example.com/avatar.jpg')
expect(img).toHaveStyle({ width: '64px', height: '64px' })
expect(img).toHaveClass('custom-avatar')
// Trigger load to verify onError callback
fireEvent.load(img)
expect(onErrorMock).toHaveBeenCalledWith(false)
expect(screen.getByRole('img')).toBeInTheDocument()
})
it('should apply all fallback props correctly when used together', () => {
const props = {
name: 'Fallback User',
avatar: null,
size: 48,
className: 'fallback-custom',
textClassName: 'custom-text-style',
}
it('should not render image when avatar is null even with onLoadingStatusChange', () => {
const onStatusChange = vi.fn()
render(
<Avatar name="John" avatar={null} onLoadingStatusChange={onStatusChange} />,
)
render(<Avatar {...props} />)
const textElement = screen.getByText('F')
const outerDiv = textElement.parentElement as HTMLElement
expect(outerDiv).toHaveClass('fallback-custom')
expect(outerDiv).toHaveStyle({ width: '48px', height: '48px' })
expect(textElement).toHaveClass('custom-text-style')
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
})
})

View File

@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import Avatar from '.'
import { Avatar } from '.'
const meta = {
title: 'Base/Data Display/Avatar',
@ -7,12 +7,12 @@ const meta = {
parameters: {
docs: {
description: {
component: 'Initials or image-based avatar used across contacts and member lists. Falls back to the first letter when the image fails to load.',
component: 'Initials or image-based avatar built on Base UI. Falls back to the first letter when the image fails to load.',
},
source: {
language: 'tsx',
code: `
<Avatar name="Alex Doe" avatar="https://cloud.dify.ai/logo/logo.svg" size={40} />
<Avatar name="Alex Doe" avatar="https://i.pravatar.cc/96?u=avatar-default" size="xl" />
`.trim(),
},
},
@ -20,8 +20,8 @@ const meta = {
tags: ['autodocs'],
args: {
name: 'Alex Doe',
avatar: 'https://cloud.dify.ai/logo/logo.svg',
size: 40,
avatar: 'https://i.pravatar.cc/96?u=avatar-default',
size: 'xl',
},
} satisfies Meta<typeof Avatar>
@ -40,23 +40,20 @@ export const WithFallback: Story = {
source: {
language: 'tsx',
code: `
<Avatar name="Fallback" avatar={null} size={40} />
<Avatar name="Fallback" avatar={null} size="xl" />
`.trim(),
},
},
},
}
export const CustomSizes: Story = {
export const AllSizes: Story = {
render: args => (
<div className="flex items-end gap-4">
{[24, 32, 48, 64].map(size => (
{(['xxs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl'] as const).map(size => (
<div key={size} className="flex flex-col items-center gap-2">
<Avatar {...args} size={size} avatar="https://i.pravatar.cc/96?u=size-test" />
<span className="text-xs text-text-tertiary">
{size}
px
</span>
<span className="text-xs text-text-tertiary">{size}</span>
</div>
))}
</div>
@ -66,7 +63,7 @@ export const CustomSizes: Story = {
source: {
language: 'tsx',
code: `
{[24, 32, 48, 64].map(size => (
{(['xxs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl'] as const).map(size => (
<Avatar key={size} name="Size Test" size={size} avatar="https://i.pravatar.cc/96?u=size-test" />
))}
`.trim(),
@ -74,3 +71,16 @@ export const CustomSizes: Story = {
},
},
}
export const AllFallbackSizes: Story = {
render: args => (
<div className="flex items-end gap-4">
{(['xxs', 'xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl'] as const).map(size => (
<div key={size} className="flex flex-col items-center gap-2">
<Avatar {...args} size={size} avatar={null} name="Alex" />
<span className="text-xs text-text-tertiary">{size}</span>
</div>
))}
</div>
),
}

View File

@ -1,64 +1,52 @@
'use client'
import { useEffect, useState } from 'react'
import type { ImageLoadingStatus } from '@base-ui/react/avatar'
import { Avatar as BaseAvatar } from '@base-ui/react/avatar'
import { cn } from '@/utils/classnames'
const SIZES = {
'xxs': { root: 'size-4', text: 'text-[7px]' },
'xs': { root: 'size-5', text: 'text-[8px]' },
'sm': { root: 'size-6', text: 'text-[10px]' },
'md': { root: 'size-8', text: 'text-xs' },
'lg': { root: 'size-9', text: 'text-sm' },
'xl': { root: 'size-10', text: 'text-base' },
'2xl': { root: 'size-12', text: 'text-xl' },
'3xl': { root: 'size-16', text: 'text-2xl' },
} as const
export type AvatarSize = keyof typeof SIZES
export type AvatarProps = {
name: string
avatar: string | null
size?: number
size?: AvatarSize
className?: string
textClassName?: string
onError?: (x: boolean) => void
onLoadingStatusChange?: (status: ImageLoadingStatus) => void
}
const Avatar = ({
const BASE_CLASS = 'relative inline-flex shrink-0 select-none items-center justify-center overflow-hidden rounded-full bg-primary-600'
export const Avatar = ({
name,
avatar,
size = 30,
size = 'md',
className,
textClassName,
onError,
onLoadingStatusChange,
}: AvatarProps) => {
const avatarClassName = 'shrink-0 flex items-center rounded-full bg-primary-600'
const style = { width: `${size}px`, height: `${size}px`, fontSize: `${size}px`, lineHeight: `${size}px` }
const [imgError, setImgError] = useState(false)
const handleError = () => {
setImgError(true)
onError?.(true)
}
// after uploaded, api would first return error imgs url: '.../files//file-preview/...'. Then return the right url, Which caused not show the avatar
useEffect(() => {
if (avatar && imgError)
setImgError(false)
}, [avatar])
if (avatar && !imgError) {
return (
<img
className={cn(avatarClassName, className)}
style={style}
alt={name}
src={avatar}
onError={handleError}
onLoad={() => onError?.(false)}
/>
)
}
const sizeConfig = SIZES[size]
return (
<div
className={cn(avatarClassName, className)}
style={style}
>
<div
className={cn(textClassName, 'scale-[0.4] text-center text-white')}
style={style}
>
{name && name[0].toLocaleUpperCase()}
</div>
</div>
<BaseAvatar.Root className={cn(BASE_CLASS, sizeConfig.root, className)}>
{avatar && (
<BaseAvatar.Image
src={avatar}
alt={name}
className="absolute inset-0 size-full object-cover"
onLoadingStatusChange={onLoadingStatusChange}
/>
)}
<BaseAvatar.Fallback className={cn('font-medium text-white', sizeConfig.text)}>
{name?.[0]?.toLocaleUpperCase()}
</BaseAvatar.Fallback>
</BaseAvatar.Root>
)
}
export default Avatar

View File

@ -23,7 +23,7 @@ import { submitHumanInputForm as submitHumanInputFormService } from '@/service/w
import { TransferMethod } from '@/types/app'
import { cn } from '@/utils/classnames'
import { formatBooleanInputs } from '@/utils/model-config'
import Avatar from '../../avatar'
import { Avatar } from '../../avatar'
import Chat from '../chat'
import { useChat } from '../chat/hooks'
import { getLastAnswer, isValidGeneratedAnswer } from '../utils'
@ -351,7 +351,7 @@ const ChatWrapper = () => {
<Avatar
avatar={initUserVariables.avatar_url}
name={initUserVariables.name || 'user'}
size={40}
size="xl"
/>
)
: undefined

View File

@ -23,7 +23,7 @@ import {
import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
import { TransferMethod } from '@/types/app'
import { cn } from '@/utils/classnames'
import Avatar from '../../avatar'
import { Avatar } from '../../avatar'
import Chat from '../chat'
import { useChat } from '../chat/hooks'
import { getLastAnswer, isValidGeneratedAnswer } from '../utils'
@ -337,7 +337,7 @@ const ChatWrapper = () => {
<Avatar
avatar={initUserVariables.avatar_url}
name={initUserVariables.name || 'user'}
size={40}
size="xl"
/>
)
: undefined