mirror of
https://github.com/langgenius/dify.git
synced 2026-05-12 15:58:19 +08:00
fix(web): snippet card
This commit is contained in:
parent
3a7f09a250
commit
42889d23e5
@ -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' })
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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": "变量查看"
|
||||
}
|
||||
|
||||
@ -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'>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user