fix(web): snippet card

This commit is contained in:
JzoNg 2026-04-28 16:05:26 +08:00
parent 3a7f09a250
commit 42889d23e5
12 changed files with 147 additions and 41 deletions

View File

@ -160,8 +160,9 @@ const defaultSnippetData = {
icon_url: '',
},
created_at: 1704067200,
updated_at: '2024-01-02 10:00',
author: '',
created_by: 'user-1',
updated_at: 1704153600,
updated_by: 'user-2',
},
],
total: 1,
@ -321,8 +322,9 @@ describe('List', () => {
icon_url: '',
},
created_at: 1704067200,
updated_at: '2024-01-02 10:00',
author: '',
created_by: 'user-1',
updated_at: 1704153600,
updated_by: 'user-2',
},
]
defaultSnippetData.pages[0]!.total = 1
@ -678,8 +680,8 @@ describe('List', () => {
})
it('should reuse the shared empty state when no snippets are available', () => {
defaultSnippetData.pages[0].data = []
defaultSnippetData.pages[0].total = 0
defaultSnippetData.pages[0]!.data = []
defaultSnippetData.pages[0]!.total = 0
renderList({ pageType: 'snippets' })

View File

@ -0,0 +1,60 @@
import type { SnippetListItem } from '@/types/snippet'
import { render, screen } from '@testing-library/react'
import SnippetCard from '../snippet-card'
vi.mock('@/service/use-common', () => ({
useMembers: () => ({
data: {
accounts: [
{ id: 'creator-id', name: 'Creator', email: 'creator@example.com', avatar: '', avatar_url: null, role: 'editor', last_login_at: '', created_at: '', status: 'active' },
{ id: 'updater-id', name: 'Updater', email: 'updater@example.com', avatar: '', avatar_url: null, role: 'editor', last_login_at: '', created_at: '', status: 'active' },
],
},
}),
}))
vi.mock('@/utils/time', () => ({
formatTime: () => 'formatted-time',
}))
const createSnippet = (overrides: Partial<SnippetListItem> = {}): SnippetListItem => ({
id: 'snippet-1',
name: 'Tone Rewriter',
description: 'Rewrites rough drafts.',
type: 'node',
is_published: true,
use_count: 19,
icon_info: {
icon_type: 'emoji',
icon: '🪄',
icon_background: '#E0EAFF',
icon_url: '',
},
created_at: 1_704_067_200,
created_by: 'creator-id',
updated_at: 1_704_153_600,
updated_by: 'updater-id',
...overrides,
})
describe('SnippetCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render updater name and updated time from member data', () => {
render(<SnippetCard snippet={createSnippet()} />)
expect(screen.getByText('Tone Rewriter')).toBeInTheDocument()
expect(screen.getByText('snippet.updatedBy:{"name":"Updater","time":"formatted-time"}')).toBeInTheDocument()
expect(screen.queryByText('Creator')).not.toBeInTheDocument()
})
it('should fall back to creator name when updater is unavailable', () => {
render(<SnippetCard snippet={createSnippet({ updated_by: 'missing-user' })} />)
expect(screen.getByText('snippet.updatedBy:{"name":"Creator","time":"formatted-time"}')).toBeInTheDocument()
})
})
})

View File

@ -1,9 +1,12 @@
'use client'
import type { SnippetListItem } from '@/types/snippet'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import Link from '@/next/link'
import { useMembers } from '@/service/use-common'
import { formatTime } from '@/utils/time'
type Props = {
snippet: SnippetListItem
@ -11,16 +14,35 @@ type Props = {
const SnippetCard = ({ snippet }: Props) => {
const { t } = useTranslation('snippet')
const { data: membersData } = useMembers()
const memberNameById = useMemo(() => {
return new Map((membersData?.accounts ?? []).map(member => [member.id, member.name]))
}, [membersData?.accounts])
const updatedByName = memberNameById.get(snippet.updated_by)
|| memberNameById.get(snippet.created_by)
|| t('unknownUser')
const updatedAt = snippet.updated_at || snippet.created_at
const updatedAtText = formatTime({
date: (updatedAt > 1_000_000_000_000 ? updatedAt : updatedAt * 1000),
dateFormat: `${t('segment.dateTimeFormat', { ns: 'datasetDocuments' })}`,
})
const updatedText = t('updatedBy', {
name: updatedByName,
time: updatedAtText,
})
return (
<Link href={`/snippets/${snippet.id}/orchestrate`} className="group col-span-1">
<article className="relative inline-flex h-[160px] w-full flex-col rounded-xl border border-components-card-border bg-components-card-bg shadow-sm transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-lg">
{!snippet.is_published && (
<div className="absolute right-0 top-0 rounded-bl-lg rounded-tr-xl bg-background-default-dimmed px-2 py-1 text-[10px] font-medium uppercase leading-3 text-text-placeholder">
Draft
<div className="absolute top-0 right-0 rounded-tr-xl rounded-bl-lg bg-background-default-dimmed px-2 py-1 text-[10px] leading-3 font-medium text-text-placeholder uppercase">
{t('draft')}
</div>
)}
<div className="flex h-[66px] items-center gap-3 px-[14px] pb-3 pt-[14px]">
<div className="flex h-[66px] items-center gap-3 px-[14px] pt-[14px] pb-3">
<AppIcon
size="large"
iconType={snippet.icon_info.icon_type}
@ -29,7 +51,7 @@ const SnippetCard = ({ snippet }: Props) => {
imageUrl={snippet.icon_info.icon_url}
/>
<div className="w-0 grow py-[1px]">
<div className="truncate text-sm font-semibold leading-5 text-text-secondary" title={snippet.name}>
<div className="truncate text-sm leading-5 font-semibold text-text-secondary" title={snippet.name}>
{snippet.name}
</div>
</div>
@ -39,11 +61,9 @@ const SnippetCard = ({ snippet }: Props) => {
{snippet.description}
</div>
</div>
<div className="mt-auto flex items-center gap-1 px-[14px] pb-3 pt-2 text-xs leading-4 text-text-tertiary">
<span className="truncate">{snippet.author}</span>
<span>·</span>
<span className="truncate">{snippet.updated_at}</span>
{!snippet.is_published && (
<div className="mt-auto flex items-center gap-1 px-[14px] pt-2 pb-3 text-xs leading-4 text-text-tertiary">
<span className="truncate" title={updatedText}>{updatedText}</span>
{snippet.is_published && (
<>
<span>·</span>
<span className="truncate">{t('usageCount', { count: snippet.use_count })}</span>

View File

@ -72,8 +72,9 @@ describe('useSnippetInit', () => {
},
input_fields: [],
created_at: 1_712_300_000,
created_by: 'user-1',
updated_at: 1_712_300_000,
author: 'Evan',
updated_by: 'user-1',
},
error: null,
isLoading: false,
@ -124,8 +125,9 @@ describe('useSnippetInit', () => {
},
],
created_at: 1_712_300_000,
created_by: 'user-1',
updated_at: 1_712_300_000,
author: 'Evan',
updated_by: 'user-1',
},
error: null,
isLoading: false,

View File

@ -74,7 +74,6 @@ describe('Snippets', () => {
id: 'snippet-1',
name: 'Customer Review',
description: 'Snippet description',
author: 'Evan',
type: 'group',
is_published: true,
version: '1.0.0',
@ -87,7 +86,9 @@ describe('Snippets', () => {
},
input_fields: [],
created_at: 1,
created_by: 'user-1',
updated_at: 2,
updated_by: 'user-1',
}],
}],
},
@ -127,7 +128,6 @@ describe('Snippets', () => {
id: 'snippet-1',
name: 'Customer Review',
description: 'Snippet description',
author: 'Evan',
type: 'group',
is_published: true,
version: '1.0.0',
@ -140,7 +140,9 @@ describe('Snippets', () => {
},
input_fields: [],
created_at: 1,
created_by: 'user-1',
updated_at: 2,
updated_by: 'user-1',
}],
}],
},
@ -155,7 +157,7 @@ describe('Snippets', () => {
fireEvent.click(screen.getByText('Customer Review'))
expect(mockHandleInsertSnippet).toHaveBeenCalledWith('snippet-1')
expect(mockHandleInsertSnippet).toHaveBeenCalledWith('snippet-1', undefined)
})
})
})

View File

@ -8,11 +8,20 @@ vi.mock('@/service/use-snippet-workflows', () => ({
useSnippetPublishedWorkflow: (...args: unknown[]) => mockUseSnippetPublishedWorkflow(...args),
}))
vi.mock('@/service/use-common', () => ({
useMembers: () => ({
data: {
accounts: [
{ id: 'user-1', name: 'Evan', email: 'evan@example.com', avatar: '', avatar_url: null, role: 'editor', last_login_at: '', created_at: '', status: 'active' },
],
},
}),
}))
const createSnippet = (overrides: Partial<PublishedSnippetListItem> = {}): PublishedSnippetListItem => ({
id: 'snippet-1',
name: 'Customer Review',
description: 'Snippet description',
author: 'Evan',
type: 'group',
is_published: true,
use_count: 3,
@ -23,7 +32,9 @@ const createSnippet = (overrides: Partial<PublishedSnippetListItem> = {}): Publi
icon_url: '',
},
created_at: 1,
created_by: 'user-1',
updated_at: 2,
updated_by: 'user-1',
...overrides,
})

View File

@ -6,7 +6,6 @@ const createSnippet = (overrides: Partial<PublishedSnippetListItem> = {}): Publi
id: 'snippet-1',
name: 'Customer Review',
description: 'Snippet description',
author: 'Evan',
type: 'group',
is_published: true,
use_count: 3,
@ -17,7 +16,9 @@ const createSnippet = (overrides: Partial<PublishedSnippetListItem> = {}): Publi
icon_url: '',
},
created_at: 1,
created_by: 'user-1',
updated_at: 2,
updated_by: 'user-1',
...overrides,
})
@ -38,10 +39,9 @@ describe('SnippetListItem', () => {
)
expect(screen.getByText('Customer Review')).toBeInTheDocument()
expect(screen.queryByText('Evan')).not.toBeInTheDocument()
})
it('should render author when hovered', () => {
it('should not render metadata when hovered', () => {
render(
<SnippetListItem
snippet={createSnippet()}
@ -51,7 +51,7 @@ describe('SnippetListItem', () => {
/>,
)
expect(screen.getByText('Evan')).toBeInTheDocument()
expect(screen.getByText('Customer Review')).toBeInTheDocument()
})
})

View File

@ -1,7 +1,9 @@
import type { FC } from 'react'
import type { SnippetListItem } from '@/types/snippet'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import { useMembers } from '@/service/use-common'
import { useSnippetPublishedWorkflow } from '@/service/use-snippet-workflows'
import BlockIcon from '../../block-icon'
import { BlockEnum } from '../../types'
@ -15,9 +17,16 @@ type SnippetDetailCardProps = {
const SnippetDetailCard: FC<SnippetDetailCardProps> = ({
snippet,
}) => {
const { author, description, icon_info, name } = snippet
const { description, icon_info, name } = snippet
const { t } = useTranslation('snippet')
const { data: membersData } = useMembers()
const { data: workflow } = useSnippetPublishedWorkflow(snippet.id)
const authorName = useMemo(() => {
const member = membersData?.accounts?.find(member => member.id === snippet.created_by)
return member?.name || t('unknownUser')
}, [membersData?.accounts, snippet.created_by, t])
const blockTypes = useMemo(() => {
const graph = workflow?.graph
if (!graph || typeof graph !== 'object')
@ -51,7 +60,7 @@ const SnippetDetailCard: FC<SnippetDetailCardProps> = ({
}, [workflow?.graph])
return (
<div className="w-[224px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-3 pb-4 pt-3 shadow-lg backdrop-blur-[5px]">
<div className="w-[224px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-3 pt-3 pb-4 shadow-lg backdrop-blur-[5px]">
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2">
<AppIcon
@ -61,10 +70,10 @@ const SnippetDetailCard: FC<SnippetDetailCardProps> = ({
background={icon_info.icon_background}
imageUrl={icon_info.icon_url}
/>
<div className="text-text-primary system-md-medium">{name}</div>
<div className="system-md-medium text-text-primary">{name}</div>
</div>
{!!description && (
<div className="w-[200px] text-text-secondary system-xs-regular">
<div className="w-[200px] system-xs-regular text-text-secondary">
{description}
</div>
)}
@ -80,11 +89,9 @@ const SnippetDetailCard: FC<SnippetDetailCardProps> = ({
</div>
)}
</div>
{!!author && (
<div className="pt-3 text-text-tertiary system-xs-regular">
{author}
</div>
)}
<div className="pt-3 system-xs-regular text-text-tertiary">
{authorName}
</div>
</div>
)
}

View File

@ -36,14 +36,9 @@ const SnippetListItem = ({
background={snippet.icon_info.icon_background}
imageUrl={snippet.icon_info.icon_url}
/>
<div className="system-sm-medium min-w-0 text-text-secondary">
<div className="min-w-0 system-sm-medium text-text-secondary">
{snippet.name}
</div>
{isHovered && snippet.author && (
<div className="system-xs-regular ml-auto text-text-tertiary">
{snippet.author}
</div>
)}
</div>
)
}

View File

@ -7,6 +7,7 @@
"deleteConfirmTitle": "Delete Snippet?",
"deleteFailed": "Failed to delete snippet",
"deleted": "Snippet deleted",
"draft": "Draft",
"editDialogTitle": "Edit Snippet Info",
"editDone": "Snippet info updated",
"editFailed": "Failed to update snippet info",
@ -32,6 +33,8 @@
"sectionOrchestrate": "Orchestrate",
"testRunButton": "Test run",
"typeLabel": "Snippet",
"unknownUser": "User",
"updatedBy": "{{name}} updated {{time}}",
"usageCount": "Used {{count}} times",
"variableInspect": "Variable Inspect"
}

View File

@ -7,6 +7,7 @@
"deleteConfirmTitle": "删除 Snippet",
"deleteFailed": "删除 Snippet 失败",
"deleted": "Snippet 已删除",
"draft": "草稿",
"editDialogTitle": "编辑 Snippet 信息",
"editDone": "Snippet 信息已更新",
"editFailed": "更新 Snippet 信息失败",
@ -32,6 +33,8 @@
"sectionOrchestrate": "编排",
"testRunButton": "测试运行",
"typeLabel": "Snippet",
"unknownUser": "用户",
"updatedBy": "{{name}} 更新于 {{time}}",
"usageCount": "已使用 {{count}} 次",
"variableInspect": "变量查看"
}

View File

@ -22,8 +22,9 @@ export type Snippet = {
icon_info: SnippetIconInfo
input_fields: SnippetInputField[]
created_at: number
created_by: string
updated_at: number
author: string
updated_by: string
}
export type SnippetListItem = Omit<Snippet, 'version' | 'input_fields'>