From a4a7d5c2fa885ffe23c4b4c99d717edbdd37e2ae Mon Sep 17 00:00:00 2001 From: yyh Date: Mon, 13 Apr 2026 20:03:28 +0800 Subject: [PATCH] refactor(web): restore avatar composition API --- .../base/ui/avatar/__tests__/index.spec.tsx | 19 ++++++- .../base/ui/avatar/index.stories.tsx | 26 ++++++++- web/app/components/base/ui/avatar/index.tsx | 57 +++---------------- .../base/user-avatar-list/index.tsx | 23 +++++--- .../components/workflow/comment/thread.tsx | 23 +++++--- .../workflow/header/online-users.tsx | 47 ++++++++++----- 6 files changed, 115 insertions(+), 80 deletions(-) diff --git a/web/app/components/base/ui/avatar/__tests__/index.spec.tsx b/web/app/components/base/ui/avatar/__tests__/index.spec.tsx index 8be3f8bf0f..8a384139c2 100644 --- a/web/app/components/base/ui/avatar/__tests__/index.spec.tsx +++ b/web/app/components/base/ui/avatar/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react' -import { Avatar } from '..' +import { Avatar, AvatarFallback, AvatarImage, AvatarRoot } from '..' describe('Avatar', () => { describe('Rendering', () => { @@ -60,6 +60,23 @@ describe('Avatar', () => { }) }) + describe('Primitives', () => { + it('should support composed avatar usage through exported primitives', () => { + render( + + + + J + + , + ) + + expect(screen.getByTestId('avatar-root')).toHaveClass('size-6') + expect(screen.getByText('J')).toBeInTheDocument() + expect(screen.getByText('J')).toHaveStyle({ backgroundColor: 'rgb(1, 2, 3)' }) + }) + }) + describe('Edge Cases', () => { it('should handle empty string name gracefully', () => { const { container } = render() diff --git a/web/app/components/base/ui/avatar/index.stories.tsx b/web/app/components/base/ui/avatar/index.stories.tsx index bf4da697db..abb3c99771 100644 --- a/web/app/components/base/ui/avatar/index.stories.tsx +++ b/web/app/components/base/ui/avatar/index.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { Avatar } from '.' +import { Avatar, AvatarFallback, AvatarRoot } from '.' const meta = { title: 'Base/Data Display/Avatar', @@ -84,3 +84,27 @@ export const AllFallbackSizes: Story = { ), } + +export const ComposedFallback: Story = { + render: () => ( + + + C + + + ), + parameters: { + docs: { + source: { + language: 'tsx', + code: ` + + + C + + + `.trim(), + }, + }, + }, +} diff --git a/web/app/components/base/ui/avatar/index.tsx b/web/app/components/base/ui/avatar/index.tsx index b92dd7e838..a1fd392654 100644 --- a/web/app/components/base/ui/avatar/index.tsx +++ b/web/app/components/base/ui/avatar/index.tsx @@ -21,31 +21,18 @@ export type AvatarProps = { avatar: string | null size?: AvatarSize className?: string - textClassName?: string - onError?: (hasError: boolean) => void - backgroundColor?: string onLoadingStatusChange?: (status: ImageLoadingStatus) => void } -type AvatarRootProps = React.ComponentPropsWithRef & { +export type AvatarRootProps = React.ComponentPropsWithRef & { size?: AvatarSize - hasAvatar?: boolean - backgroundColor?: string } -function AvatarRoot({ +export function AvatarRoot({ size = 'md', className, - hasAvatar = false, - backgroundColor, - style, ...props }: AvatarRootProps) { - const resolvedStyle: React.CSSProperties = { - ...(backgroundColor && !hasAvatar ? { backgroundColor } : {}), - ...style, - } - return ( ) } -type AvatarFallbackProps = React.ComponentPropsWithRef & { +export type AvatarFallbackProps = React.ComponentPropsWithRef & { size?: AvatarSize - textClassName?: string } -function AvatarFallback({ +export function AvatarFallback({ size = 'md', - textClassName, className, - style, ...props }: AvatarFallbackProps) { - const resolvedStyle: React.CSSProperties = { - ...style, - } - return ( ) } -type AvatarImageProps = React.ComponentPropsWithRef +export type AvatarImageProps = React.ComponentPropsWithRef -function AvatarImage({ +export function AvatarImage({ className, ...props }: AvatarImageProps) { @@ -108,34 +85,18 @@ export const Avatar = ({ avatar, size = 'md', className, - textClassName, - onError, - backgroundColor, onLoadingStatusChange, }: AvatarProps) => { - const handleLoadingStatusChange = (status: ImageLoadingStatus) => { - onLoadingStatusChange?.(status) - if (status === 'error') - onError?.(true) - if (status === 'loaded') - onError?.(false) - } - return ( - + {avatar && ( )} - + {name?.[0]?.toLocaleUpperCase()} diff --git a/web/app/components/base/user-avatar-list/index.tsx b/web/app/components/base/user-avatar-list/index.tsx index 9ee2b571d5..fc2ce60572 100644 --- a/web/app/components/base/user-avatar-list/index.tsx +++ b/web/app/components/base/user-avatar-list/index.tsx @@ -1,7 +1,7 @@ import type { FC } from 'react' import type { AvatarSize } from '@/app/components/base/ui/avatar' import { memo } from 'react' -import { Avatar } from '@/app/components/base/ui/avatar' +import { AvatarFallback, AvatarImage, AvatarRoot } from '@/app/components/base/ui/avatar' import { getUserColor } from '@/app/components/workflow/collaboration/utils/user-color' import { useAppContext } from '@/context/app-context' @@ -59,13 +59,20 @@ export const UserAvatarList: FC = memo(({ className="relative" style={{ zIndex: visibleUsers.length - index }} > - + + {user.avatar_url && ( + + )} + + {user.name?.[0]?.toLocaleUpperCase()} + + ) }, diff --git a/web/app/components/workflow/comment/thread.tsx b/web/app/components/workflow/comment/thread.tsx index 2d699d7931..21c327e8a4 100644 --- a/web/app/components/workflow/comment/thread.tsx +++ b/web/app/components/workflow/comment/thread.tsx @@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next' import { useReactFlow, useViewport } from 'reactflow' import Divider from '@/app/components/base/divider' import InlineDeleteConfirm from '@/app/components/base/inline-delete-confirm' -import { Avatar } from '@/app/components/base/ui/avatar' +import { Avatar, AvatarFallback, AvatarImage, AvatarRoot } from '@/app/components/base/ui/avatar' import { DropdownMenu, DropdownMenuContent, @@ -124,13 +124,20 @@ const ThreadMessage: FC<{ return (
- + + {avatarUrl && ( + + )} + + {authorName?.[0]?.toLocaleUpperCase()} + +
diff --git a/web/app/components/workflow/header/online-users.tsx b/web/app/components/workflow/header/online-users.tsx index d7a41d1a3d..895600d07a 100644 --- a/web/app/components/workflow/header/online-users.tsx +++ b/web/app/components/workflow/header/online-users.tsx @@ -4,7 +4,7 @@ import { ChevronDownIcon } from '@heroicons/react/20/solid' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useReactFlow } from 'reactflow' -import { Avatar } from '@/app/components/base/ui/avatar' +import { AvatarFallback, AvatarImage, AvatarRoot } from '@/app/components/base/ui/avatar' import { Popover, PopoverContent, @@ -123,6 +123,8 @@ const OnlineUsers = () => { {visibleUsers.map((user, index) => { const isCurrentUser = user.user_id === currentUserId const userColor = isCurrentUser ? undefined : getUserColor(user.user_id) + const avatarUrl = getAvatarUrl(user) + const displayName = user.username || fallbackUsername return ( @@ -135,13 +137,20 @@ const OnlineUsers = () => { style={{ zIndex: visibleUsers.length - index }} onClick={() => !isCurrentUser && jumpToUserCursor(user.user_id)} > - + + {avatarUrl && ( + + )} + + {displayName?.[0]?.toLocaleUpperCase()} + +
{ {onlineUsers.map((user) => { const isCurrentUser = user.user_id === currentUserId const userColor = isCurrentUser ? undefined : getUserColor(user.user_id) + const avatarUrl = getAvatarUrl(user) + const displayName = user.username || fallbackUsername return (
{ }} >
- + + {avatarUrl && ( + + )} + + {displayName?.[0]?.toLocaleUpperCase()} + +
{renderDisplayName( user,