mirror of
https://github.com/langgenius/dify.git
synced 2026-03-31 04:51:47 +00:00
Compare commits
74 Commits
codex/forc
...
feat/evalu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92c472ccc7 | ||
|
|
b92b8becd1 | ||
|
|
23d0d6a65d | ||
|
|
1660067d6e | ||
|
|
0642475b85 | ||
|
|
8cb634c9bc | ||
|
|
768b41c3cf | ||
|
|
ca88516d54 | ||
|
|
871a2a149f | ||
|
|
60e381eff0 | ||
|
|
768b3eb6f9 | ||
|
|
2f88da4a6d | ||
|
|
a8cdf6964c | ||
|
|
985c3db4fd | ||
|
|
9636472db7 | ||
|
|
0ad268aa7d | ||
|
|
a4ea33167d | ||
|
|
0f13aabea8 | ||
|
|
1e76ef5ccb | ||
|
|
e6e3229d17 | ||
|
|
dccf8e723a | ||
|
|
c41ba7d627 | ||
|
|
a6e9316de3 | ||
|
|
559d326cbd | ||
|
|
abedf2506f | ||
|
|
d01428b5bc | ||
|
|
0de1f17e5c | ||
|
|
17d07a5a43 | ||
|
|
3bdbea99a3 | ||
|
|
b7683aedb1 | ||
|
|
515036e758 | ||
|
|
22b382527f | ||
|
|
2cfe4b5b86 | ||
|
|
6876c8041c | ||
|
|
7de45584ce | ||
|
|
5572d7c7e8 | ||
|
|
db0a2fe52e | ||
|
|
f0ae8d6167 | ||
|
|
2514e181ba | ||
|
|
be2e6e9a14 | ||
|
|
875e2eac1b | ||
|
|
c3c73ceb1f | ||
|
|
6318bf0a2a | ||
|
|
5e1f252046 | ||
|
|
df3b960505 | ||
|
|
26bc108bf1 | ||
|
|
a5cff32743 | ||
|
|
d418dd8eec | ||
|
|
61702fe346 | ||
|
|
43f0c780c3 | ||
|
|
30ebf2bfa9 | ||
|
|
7e3027b5f7 | ||
|
|
b3acf83090 | ||
|
|
36c3d6e48a | ||
|
|
f782ac6b3c | ||
|
|
feef2dd1fa | ||
|
|
a716d8789d | ||
|
|
6816f89189 | ||
|
|
bfcac64a9d | ||
|
|
664eb601a2 | ||
|
|
8e5cc4e0aa | ||
|
|
9f28575903 | ||
|
|
4b9a26a5e6 | ||
|
|
7b85adf1cc | ||
|
|
c964708ebe | ||
|
|
883eb498c0 | ||
|
|
4d3738d225 | ||
|
|
dd0dee739d | ||
|
|
4d19914fcb | ||
|
|
887c7710e9 | ||
|
|
7a722773c7 | ||
|
|
a763aff58b | ||
|
|
c1011f4e5c | ||
|
|
f7afa103a5 |
3
.github/workflows/api-tests.yml
vendored
3
.github/workflows/api-tests.yml
vendored
@@ -9,9 +9,6 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
concurrency:
|
||||
group: api-tests-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
3
.github/workflows/autofix.yml
vendored
3
.github/workflows/autofix.yml
vendored
@@ -10,9 +10,6 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
autofix:
|
||||
if: github.repository == 'langgenius/dify'
|
||||
|
||||
3
.github/workflows/db-migration-test.yml
vendored
3
.github/workflows/db-migration-test.yml
vendored
@@ -3,9 +3,6 @@ name: DB Migration Test
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
concurrency:
|
||||
group: db-migration-test-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
3
.github/workflows/main-ci.yml
vendored
3
.github/workflows/main-ci.yml
vendored
@@ -16,9 +16,6 @@ permissions:
|
||||
checks: write
|
||||
statuses: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
concurrency:
|
||||
group: main-ci-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
3
.github/workflows/vdb-tests.yml
vendored
3
.github/workflows/vdb-tests.yml
vendored
@@ -3,9 +3,6 @@ name: Run VDB Tests
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
concurrency:
|
||||
group: vdb-tests-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import Evaluation from '@/app/components/evaluation'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ appId: string }>
|
||||
}) => {
|
||||
const { appId } = await props.params
|
||||
|
||||
return <Evaluation resourceType="workflow" resourceId={appId} />
|
||||
}
|
||||
|
||||
export default Page
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
RiDashboard2Line,
|
||||
RiFileList3Fill,
|
||||
RiFileList3Line,
|
||||
RiFlaskFill,
|
||||
RiFlaskLine,
|
||||
RiTerminalBoxFill,
|
||||
RiTerminalBoxLine,
|
||||
RiTerminalWindowFill,
|
||||
@@ -67,40 +69,47 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
}>>([])
|
||||
|
||||
const getNavigationConfig = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: AppModeEnum) => {
|
||||
const navConfig = [
|
||||
...(isCurrentWorkspaceEditor
|
||||
? [{
|
||||
name: t('appMenus.promptEng', { ns: 'common' }),
|
||||
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
|
||||
icon: RiTerminalWindowLine,
|
||||
selectedIcon: RiTerminalWindowFill,
|
||||
}]
|
||||
: []
|
||||
),
|
||||
{
|
||||
name: t('appMenus.apiAccess', { ns: 'common' }),
|
||||
href: `/app/${appId}/develop`,
|
||||
icon: RiTerminalBoxLine,
|
||||
selectedIcon: RiTerminalBoxFill,
|
||||
},
|
||||
...(isCurrentWorkspaceEditor
|
||||
? [{
|
||||
name: mode !== AppModeEnum.WORKFLOW
|
||||
? t('appMenus.logAndAnn', { ns: 'common' })
|
||||
: t('appMenus.logs', { ns: 'common' }),
|
||||
href: `/app/${appId}/logs`,
|
||||
icon: RiFileList3Line,
|
||||
selectedIcon: RiFileList3Fill,
|
||||
}]
|
||||
: []
|
||||
),
|
||||
{
|
||||
name: t('appMenus.overview', { ns: 'common' }),
|
||||
href: `/app/${appId}/overview`,
|
||||
icon: RiDashboard2Line,
|
||||
selectedIcon: RiDashboard2Fill,
|
||||
},
|
||||
]
|
||||
const navConfig = []
|
||||
|
||||
if (isCurrentWorkspaceEditor) {
|
||||
navConfig.push({
|
||||
name: t('appMenus.promptEng', { ns: 'common' }),
|
||||
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
|
||||
icon: RiTerminalWindowLine,
|
||||
selectedIcon: RiTerminalWindowFill,
|
||||
})
|
||||
navConfig.push({
|
||||
name: t('appMenus.evaluation', { ns: 'common' }),
|
||||
href: `/app/${appId}/evaluation`,
|
||||
icon: RiFlaskLine,
|
||||
selectedIcon: RiFlaskFill,
|
||||
})
|
||||
}
|
||||
|
||||
navConfig.push({
|
||||
name: t('appMenus.apiAccess', { ns: 'common' }),
|
||||
href: `/app/${appId}/develop`,
|
||||
icon: RiTerminalBoxLine,
|
||||
selectedIcon: RiTerminalBoxFill,
|
||||
})
|
||||
|
||||
if (isCurrentWorkspaceEditor) {
|
||||
navConfig.push({
|
||||
name: mode !== AppModeEnum.WORKFLOW
|
||||
? t('appMenus.logAndAnn', { ns: 'common' })
|
||||
: t('appMenus.logs', { ns: 'common' }),
|
||||
href: `/app/${appId}/logs`,
|
||||
icon: RiFileList3Line,
|
||||
selectedIcon: RiFileList3Fill,
|
||||
})
|
||||
}
|
||||
|
||||
navConfig.push({
|
||||
name: t('appMenus.overview', { ns: 'common' }),
|
||||
href: `/app/${appId}/overview`,
|
||||
icon: RiDashboard2Line,
|
||||
selectedIcon: RiDashboard2Fill,
|
||||
})
|
||||
return navConfig
|
||||
}, [t])
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import Evaluation from '@/app/components/evaluation'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ datasetId: string }>
|
||||
}) => {
|
||||
const { datasetId } = await props.params
|
||||
|
||||
return <Evaluation resourceType="pipeline" resourceId={datasetId} />
|
||||
}
|
||||
|
||||
export default Page
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
RiEqualizer2Line,
|
||||
RiFileTextFill,
|
||||
RiFileTextLine,
|
||||
RiFlaskFill,
|
||||
RiFlaskLine,
|
||||
RiFocus2Fill,
|
||||
RiFocus2Line,
|
||||
} from '@remixicon/react'
|
||||
@@ -86,20 +88,30 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
|
||||
]
|
||||
|
||||
if (datasetRes?.provider !== 'external') {
|
||||
baseNavigation.unshift({
|
||||
name: t('datasetMenus.pipeline', { ns: 'common' }),
|
||||
href: `/datasets/${datasetId}/pipeline`,
|
||||
icon: PipelineLine as RemixiconComponentType,
|
||||
selectedIcon: PipelineFill as RemixiconComponentType,
|
||||
disabled: false,
|
||||
})
|
||||
baseNavigation.unshift({
|
||||
name: t('datasetMenus.documents', { ns: 'common' }),
|
||||
href: `/datasets/${datasetId}/documents`,
|
||||
icon: RiFileTextLine,
|
||||
selectedIcon: RiFileTextFill,
|
||||
disabled: isButtonDisabledWithPipeline,
|
||||
})
|
||||
return [
|
||||
{
|
||||
name: t('datasetMenus.documents', { ns: 'common' }),
|
||||
href: `/datasets/${datasetId}/documents`,
|
||||
icon: RiFileTextLine,
|
||||
selectedIcon: RiFileTextFill,
|
||||
disabled: isButtonDisabledWithPipeline,
|
||||
},
|
||||
{
|
||||
name: t('datasetMenus.pipeline', { ns: 'common' }),
|
||||
href: `/datasets/${datasetId}/pipeline`,
|
||||
icon: PipelineLine as RemixiconComponentType,
|
||||
selectedIcon: PipelineFill as RemixiconComponentType,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
name: t('datasetMenus.evaluation', { ns: 'common' }),
|
||||
href: `/datasets/${datasetId}/evaluation`,
|
||||
icon: RiFlaskLine,
|
||||
selectedIcon: RiFlaskFill,
|
||||
disabled: false,
|
||||
},
|
||||
...baseNavigation,
|
||||
]
|
||||
}
|
||||
|
||||
return baseNavigation
|
||||
|
||||
@@ -6,7 +6,7 @@ import Loading from '@/app/components/base/loading'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { usePathname, useRouter } from '@/next/navigation'
|
||||
|
||||
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/explore', '/tools'] as const
|
||||
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/snippets', '/explore', '/tools'] as const
|
||||
|
||||
const isPathUnderRoute = (pathname: string, route: string) => pathname === route || pathname.startsWith(`${route}/`)
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import SnippetEvaluationPage from '@/app/components/snippets/snippet-evaluation-page'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ snippetId: string }>
|
||||
}) => {
|
||||
const { snippetId } = await props.params
|
||||
|
||||
return <SnippetEvaluationPage snippetId={snippetId} />
|
||||
}
|
||||
|
||||
export default Page
|
||||
@@ -0,0 +1,11 @@
|
||||
import SnippetPage from '@/app/components/snippets'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ snippetId: string }>
|
||||
}) => {
|
||||
const { snippetId } = await props.params
|
||||
|
||||
return <SnippetPage snippetId={snippetId} />
|
||||
}
|
||||
|
||||
export default Page
|
||||
21
web/app/(commonLayout)/snippets/[snippetId]/page.spec.ts
Normal file
21
web/app/(commonLayout)/snippets/[snippetId]/page.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import Page from './page'
|
||||
|
||||
const mockRedirect = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
redirect: (path: string) => mockRedirect(path),
|
||||
}))
|
||||
|
||||
describe('snippet detail redirect page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should redirect legacy snippet detail routes to orchestrate', async () => {
|
||||
await Page({
|
||||
params: Promise.resolve({ snippetId: 'snippet-1' }),
|
||||
})
|
||||
|
||||
expect(mockRedirect).toHaveBeenCalledWith('/snippets/snippet-1/orchestrate')
|
||||
})
|
||||
})
|
||||
11
web/app/(commonLayout)/snippets/[snippetId]/page.tsx
Normal file
11
web/app/(commonLayout)/snippets/[snippetId]/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ snippetId: string }>
|
||||
}) => {
|
||||
const { snippetId } = await props.params
|
||||
|
||||
redirect(`/snippets/${snippetId}/orchestrate`)
|
||||
}
|
||||
|
||||
export default Page
|
||||
7
web/app/(commonLayout)/snippets/page.tsx
Normal file
7
web/app/(commonLayout)/snippets/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import Apps from '@/app/components/apps'
|
||||
|
||||
const SnippetsPage = () => {
|
||||
return <Apps pageType="snippets" />
|
||||
}
|
||||
|
||||
export default SnippetsPage
|
||||
@@ -165,6 +165,21 @@ describe('AppDetailNav', () => {
|
||||
)
|
||||
expect(screen.queryByTestId('extra-info')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom header and navigation when provided', () => {
|
||||
render(
|
||||
<AppDetailNav
|
||||
navigation={navigation}
|
||||
renderHeader={mode => <div data-testid="custom-header" data-mode={mode} />}
|
||||
renderNavigation={mode => <div data-testid="custom-navigation" data-mode={mode} />}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('custom-header')).toHaveAttribute('data-mode', 'expand')
|
||||
expect(screen.getByTestId('custom-navigation')).toHaveAttribute('data-mode', 'expand')
|
||||
expect(screen.queryByTestId('app-info')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('nav-link-Overview')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workflow canvas mode', () => {
|
||||
|
||||
@@ -27,12 +27,16 @@ export type IAppDetailNavProps = {
|
||||
disabled?: boolean
|
||||
}>
|
||||
extraInfo?: (modeState: string) => React.ReactNode
|
||||
renderHeader?: (modeState: string) => React.ReactNode
|
||||
renderNavigation?: (modeState: string) => React.ReactNode
|
||||
}
|
||||
|
||||
const AppDetailNav = ({
|
||||
navigation,
|
||||
extraInfo,
|
||||
iconType = 'app',
|
||||
renderHeader,
|
||||
renderNavigation,
|
||||
}: IAppDetailNavProps) => {
|
||||
const { appSidebarExpand, setAppSidebarExpand } = useAppStore(useShallow(state => ({
|
||||
appSidebarExpand: state.appSidebarExpand,
|
||||
@@ -104,10 +108,11 @@ const AppDetailNav = ({
|
||||
expand ? 'p-2' : 'p-1',
|
||||
)}
|
||||
>
|
||||
{iconType === 'app' && (
|
||||
{renderHeader?.(appSidebarExpand)}
|
||||
{!renderHeader && iconType === 'app' && (
|
||||
<AppInfo expand={expand} />
|
||||
)}
|
||||
{iconType !== 'app' && (
|
||||
{!renderHeader && iconType !== 'app' && (
|
||||
<DatasetInfo expand={expand} />
|
||||
)}
|
||||
</div>
|
||||
@@ -136,7 +141,8 @@ const AppDetailNav = ({
|
||||
expand ? 'px-3 py-2' : 'p-3',
|
||||
)}
|
||||
>
|
||||
{navigation.map((item, index) => {
|
||||
{renderNavigation?.(appSidebarExpand)}
|
||||
{!renderNavigation && navigation.map((item, index) => {
|
||||
return (
|
||||
<NavLink
|
||||
key={index}
|
||||
|
||||
@@ -262,4 +262,20 @@ describe('NavLink Animation and Layout Issues', () => {
|
||||
expect(iconWrapper).toHaveClass('-ml-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Button Mode', () => {
|
||||
it('should render as an interactive button when href is omitted', () => {
|
||||
const onClick = vi.fn()
|
||||
|
||||
render(<NavLink {...mockProps} href={undefined} active={true} onClick={onClick} />)
|
||||
|
||||
const buttonElement = screen.getByText('Orchestrate').closest('button')
|
||||
expect(buttonElement).not.toBeNull()
|
||||
expect(buttonElement).toHaveClass('bg-components-menu-item-bg-active')
|
||||
expect(buttonElement).toHaveClass('text-text-accent-light-mode-only')
|
||||
|
||||
buttonElement?.click()
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,13 +14,15 @@ export type NavIcon = React.ComponentType<
|
||||
|
||||
export type NavLinkProps = {
|
||||
name: string
|
||||
href: string
|
||||
href?: string
|
||||
iconMap: {
|
||||
selected: NavIcon
|
||||
normal: NavIcon
|
||||
}
|
||||
mode?: string
|
||||
disabled?: boolean
|
||||
active?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const NavLink = ({
|
||||
@@ -29,6 +31,8 @@ const NavLink = ({
|
||||
iconMap,
|
||||
mode = 'expand',
|
||||
disabled = false,
|
||||
active,
|
||||
onClick,
|
||||
}: NavLinkProps) => {
|
||||
const segment = useSelectedLayoutSegment()
|
||||
const formattedSegment = (() => {
|
||||
@@ -39,8 +43,11 @@ const NavLink = ({
|
||||
|
||||
return res
|
||||
})()
|
||||
const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment
|
||||
const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false)
|
||||
const NavIcon = isActive ? iconMap.selected : iconMap.normal
|
||||
const linkClassName = cn(isActive
|
||||
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
|
||||
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')
|
||||
|
||||
const renderIcon = () => (
|
||||
<div className={cn(mode !== 'expand' && '-ml-1')}>
|
||||
@@ -70,13 +77,32 @@ const NavLink = ({
|
||||
)
|
||||
}
|
||||
|
||||
if (!href) {
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
className={linkClassName}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
onClick={onClick}
|
||||
>
|
||||
{renderIcon()}
|
||||
<span
|
||||
className={cn('overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out', mode === 'expand'
|
||||
? 'ml-2 max-w-none opacity-100'
|
||||
: 'ml-0 max-w-0 opacity-0')}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={name}
|
||||
href={href}
|
||||
className={cn(isActive
|
||||
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
|
||||
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')}
|
||||
className={linkClassName}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
>
|
||||
{renderIcon()}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
|
||||
import type { CreateSnippetDialogPayload } from '@/app/components/workflow/create-snippet-dialog'
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import SnippetInfoDropdown from '../dropdown'
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
const mockDownloadBlob = vi.fn()
|
||||
const mockToastSuccess = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
const mockUpdateMutate = vi.fn()
|
||||
const mockExportMutateAsync = vi.fn()
|
||||
const mockDeleteMutate = vi.fn()
|
||||
let mockDropdownOpen = false
|
||||
let mockDropdownOnOpenChange: ((open: boolean) => void) | undefined
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
replace: mockReplace,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadBlob: (args: { data: Blob, fileName: string }) => mockDownloadBlob(args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]) => mockToastSuccess(...args),
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/dropdown-menu', () => ({
|
||||
DropdownMenu: ({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: {
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
mockDropdownOpen = !!open
|
||||
mockDropdownOnOpenChange = onOpenChange
|
||||
return <div>{children}</div>
|
||||
},
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
onClick={() => mockDropdownOnOpenChange?.(!mockDropdownOpen)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
|
||||
mockDropdownOpen ? <div>{children}</div> : null
|
||||
),
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
}) => (
|
||||
<button type="button" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippets', () => ({
|
||||
useUpdateSnippetMutation: () => ({
|
||||
mutate: mockUpdateMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
useExportSnippetMutation: () => ({
|
||||
mutateAsync: mockExportMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteSnippetMutation: () => ({
|
||||
mutate: mockDeleteMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
type MockCreateSnippetDialogProps = {
|
||||
isOpen: boolean
|
||||
title?: string
|
||||
confirmText?: string
|
||||
initialValue?: {
|
||||
name?: string
|
||||
description?: string
|
||||
icon?: AppIconSelection
|
||||
}
|
||||
onClose: () => void
|
||||
onConfirm: (payload: CreateSnippetDialogPayload) => void
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/workflow/create-snippet-dialog', () => ({
|
||||
default: ({
|
||||
isOpen,
|
||||
title,
|
||||
confirmText,
|
||||
initialValue,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: MockCreateSnippetDialogProps) => {
|
||||
if (!isOpen)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div data-testid="create-snippet-dialog">
|
||||
<div>{title}</div>
|
||||
<div>{confirmText}</div>
|
||||
<div>{initialValue?.name}</div>
|
||||
<div>{initialValue?.description}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfirm({
|
||||
name: 'Updated snippet',
|
||||
description: 'Updated description',
|
||||
icon: {
|
||||
type: 'emoji',
|
||||
icon: '✨',
|
||||
background: '#FFFFFF',
|
||||
},
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
})}
|
||||
>
|
||||
submit-edit
|
||||
</button>
|
||||
<button type="button" onClick={onClose}>close-edit</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
const mockSnippet: SnippetDetail = {
|
||||
id: 'snippet-1',
|
||||
name: 'Social Media Repurposer',
|
||||
description: 'Turn one blog post into multiple social media variations.',
|
||||
author: 'Dify',
|
||||
updatedAt: '2026-03-25 10:00',
|
||||
usage: '12',
|
||||
icon: '🤖',
|
||||
iconBackground: '#F0FDF9',
|
||||
status: undefined,
|
||||
}
|
||||
|
||||
describe('SnippetInfoDropdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDropdownOpen = false
|
||||
mockDropdownOnOpenChange = undefined
|
||||
})
|
||||
|
||||
// Rendering coverage for the menu trigger itself.
|
||||
describe('Rendering', () => {
|
||||
it('should render the dropdown trigger button', () => {
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edit flow should seed the dialog with current snippet info and submit updates.
|
||||
describe('Edit Snippet', () => {
|
||||
it('should open the edit dialog and submit snippet updates', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockUpdateMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.editInfo'))
|
||||
|
||||
expect(screen.getByTestId('create-snippet-dialog')).toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.editDialogTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
|
||||
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
|
||||
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'submit-edit' }))
|
||||
|
||||
expect(mockUpdateMutate).toHaveBeenCalledWith({
|
||||
params: { snippetId: mockSnippet.id },
|
||||
body: {
|
||||
name: 'Updated snippet',
|
||||
description: 'Updated description',
|
||||
icon_info: {
|
||||
icon: '✨',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '#FFFFFF',
|
||||
icon_url: undefined,
|
||||
},
|
||||
},
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}))
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.editDone')
|
||||
})
|
||||
})
|
||||
|
||||
// Export should call the export hook and download the returned YAML blob.
|
||||
describe('Export Snippet', () => {
|
||||
it('should export and download the snippet yaml', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockExportMutateAsync.mockResolvedValue('yaml: content')
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.exportSnippet'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockExportMutateAsync).toHaveBeenCalledWith({ snippetId: mockSnippet.id })
|
||||
})
|
||||
|
||||
expect(mockDownloadBlob).toHaveBeenCalledWith({
|
||||
data: expect.any(Blob),
|
||||
fileName: `${mockSnippet.name}.yml`,
|
||||
})
|
||||
})
|
||||
|
||||
it('should show an error toast when export fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockExportMutateAsync.mockRejectedValue(new Error('export failed'))
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.exportSnippet'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastError).toHaveBeenCalledWith('snippet.exportFailed')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Delete should require confirmation and redirect after a successful mutation.
|
||||
describe('Delete Snippet', () => {
|
||||
it('should confirm deletion and redirect to the snippets list', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockDeleteMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
|
||||
options?.onSuccess?.()
|
||||
})
|
||||
|
||||
render(<SnippetInfoDropdown snippet={mockSnippet} />)
|
||||
|
||||
await user.click(screen.getByRole('button'))
|
||||
await user.click(screen.getByText('snippet.menu.deleteSnippet'))
|
||||
|
||||
expect(screen.getByText('snippet.deleteConfirmTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.deleteConfirmContent')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'snippet.menu.deleteSnippet' }))
|
||||
|
||||
expect(mockDeleteMutate).toHaveBeenCalledWith({
|
||||
params: { snippetId: mockSnippet.id },
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}))
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.deleted')
|
||||
expect(mockReplace).toHaveBeenCalledWith('/snippets')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import SnippetInfo from '..'
|
||||
|
||||
vi.mock('../dropdown', () => ({
|
||||
default: () => <div data-testid="snippet-info-dropdown" />,
|
||||
}))
|
||||
|
||||
const mockSnippet: SnippetDetail = {
|
||||
id: 'snippet-1',
|
||||
name: 'Social Media Repurposer',
|
||||
description: 'Turn one blog post into multiple social media variations.',
|
||||
author: 'Dify',
|
||||
updatedAt: '2026-03-25 10:00',
|
||||
usage: '12',
|
||||
icon: '🤖',
|
||||
iconBackground: '#F0FDF9',
|
||||
status: undefined,
|
||||
}
|
||||
|
||||
describe('SnippetInfo', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Rendering tests for the collapsed and expanded sidebar header states.
|
||||
describe('Rendering', () => {
|
||||
it('should render the expanded snippet details and dropdown when expand is true', () => {
|
||||
render(<SnippetInfo expand={true} snippet={mockSnippet} />)
|
||||
|
||||
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
|
||||
expect(screen.getByText('snippet.typeLabel')).toBeInTheDocument()
|
||||
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('snippet-info-dropdown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide the expanded-only content when expand is false', () => {
|
||||
render(<SnippetInfo expand={false} snippet={mockSnippet} />)
|
||||
|
||||
expect(screen.queryByText(mockSnippet.name)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('snippet.typeLabel')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('snippet-info-dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Edge cases around optional snippet fields should not break the header layout.
|
||||
describe('Edge Cases', () => {
|
||||
it('should omit the description block when the snippet has no description', () => {
|
||||
render(
|
||||
<SnippetInfo
|
||||
expand={true}
|
||||
snippet={{ ...mockSnippet, description: '' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
|
||||
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
197
web/app/components/app-sidebar/snippet-info/dropdown.tsx
Normal file
197
web/app/components/app-sidebar/snippet-info/dropdown.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
'use client'
|
||||
|
||||
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@/app/components/base/ui/alert-dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import CreateSnippetDialog from '@/app/components/workflow/create-snippet-dialog'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { useDeleteSnippetMutation, useExportSnippetMutation, useUpdateSnippetMutation } from '@/service/use-snippets'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
|
||||
type SnippetInfoDropdownProps = {
|
||||
snippet: SnippetDetail
|
||||
}
|
||||
|
||||
const FALLBACK_ICON: AppIconSelection = {
|
||||
type: 'emoji',
|
||||
icon: '🤖',
|
||||
background: '#FFEAD5',
|
||||
}
|
||||
|
||||
const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const { replace } = useRouter()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false)
|
||||
const updateSnippetMutation = useUpdateSnippetMutation()
|
||||
const exportSnippetMutation = useExportSnippetMutation()
|
||||
const deleteSnippetMutation = useDeleteSnippetMutation()
|
||||
|
||||
const initialValue = React.useMemo(() => ({
|
||||
name: snippet.name,
|
||||
description: snippet.description,
|
||||
icon: snippet.icon
|
||||
? {
|
||||
type: 'emoji' as const,
|
||||
icon: snippet.icon,
|
||||
background: snippet.iconBackground || FALLBACK_ICON.background,
|
||||
}
|
||||
: FALLBACK_ICON,
|
||||
}), [snippet.description, snippet.icon, snippet.iconBackground, snippet.name])
|
||||
|
||||
const handleOpenEditDialog = React.useCallback(() => {
|
||||
setOpen(false)
|
||||
setIsEditDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleExportSnippet = React.useCallback(async () => {
|
||||
setOpen(false)
|
||||
try {
|
||||
const data = await exportSnippetMutation.mutateAsync({ snippetId: snippet.id })
|
||||
const file = new Blob([data], { type: 'application/yaml' })
|
||||
downloadBlob({ data: file, fileName: `${snippet.name}.yml` })
|
||||
}
|
||||
catch {
|
||||
toast.error(t('exportFailed'))
|
||||
}
|
||||
}, [exportSnippetMutation, snippet.id, snippet.name, t])
|
||||
|
||||
const handleEditSnippet = React.useCallback(async ({ name, description, icon }: {
|
||||
name: string
|
||||
description: string
|
||||
icon: AppIconSelection
|
||||
}) => {
|
||||
updateSnippetMutation.mutate({
|
||||
params: { snippetId: snippet.id },
|
||||
body: {
|
||||
name,
|
||||
description: description || undefined,
|
||||
icon_info: {
|
||||
icon: icon.type === 'emoji' ? icon.icon : icon.fileId,
|
||||
icon_type: icon.type,
|
||||
icon_background: icon.type === 'emoji' ? icon.background : undefined,
|
||||
icon_url: icon.type === 'image' ? icon.url : undefined,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('editDone'))
|
||||
setIsEditDialogOpen(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : t('editFailed'))
|
||||
},
|
||||
})
|
||||
}, [snippet.id, t, updateSnippetMutation])
|
||||
|
||||
const handleDeleteSnippet = React.useCallback(() => {
|
||||
deleteSnippetMutation.mutate({
|
||||
params: { snippetId: snippet.id },
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success(t('deleted'))
|
||||
setIsDeleteDialogOpen(false)
|
||||
replace('/snippets')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : t('deleteFailed'))
|
||||
},
|
||||
})
|
||||
}, [deleteSnippetMutation, replace, snippet.id, t])
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
className={cn('action-btn action-btn-m size-6 rounded-md text-text-tertiary', open && 'bg-state-base-hover text-text-secondary')}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName="w-[180px] p-1"
|
||||
>
|
||||
<DropdownMenuItem className="mx-0 gap-2" onClick={handleOpenEditDialog}>
|
||||
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="grow">{t('menu.editInfo')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="mx-0 gap-2" onClick={handleExportSnippet}>
|
||||
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="grow">{t('menu.exportSnippet')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="!my-1 bg-divider-subtle" />
|
||||
<DropdownMenuItem
|
||||
className="mx-0 gap-2"
|
||||
destructive
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
|
||||
<span className="grow">{t('menu.deleteSnippet')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{isEditDialogOpen && (
|
||||
<CreateSnippetDialog
|
||||
isOpen={isEditDialogOpen}
|
||||
initialValue={initialValue}
|
||||
title={t('editDialogTitle')}
|
||||
confirmText={t('operation.save', { ns: 'common' })}
|
||||
isSubmitting={updateSnippetMutation.isPending}
|
||||
onClose={() => setIsEditDialogOpen(false)}
|
||||
onConfirm={handleEditSnippet}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent className="w-[400px]">
|
||||
<div className="space-y-2 p-6">
|
||||
<AlertDialogTitle className="text-text-primary title-lg-semi-bold">
|
||||
{t('deleteConfirmTitle')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-text-tertiary system-sm-regular">
|
||||
{t('deleteConfirmContent')}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions className="pt-0">
|
||||
<AlertDialogCancelButton>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
loading={deleteSnippetMutation.isPending}
|
||||
onClick={handleDeleteSnippet}
|
||||
>
|
||||
{t('menu.deleteSnippet')}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SnippetInfoDropdown)
|
||||
55
web/app/components/app-sidebar/snippet-info/index.tsx
Normal file
55
web/app/components/app-sidebar/snippet-info/index.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetDetail } from '@/models/snippet'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import SnippetInfoDropdown from './dropdown'
|
||||
|
||||
type SnippetInfoProps = {
|
||||
expand: boolean
|
||||
snippet: SnippetDetail
|
||||
}
|
||||
|
||||
const SnippetInfo = ({
|
||||
expand,
|
||||
snippet,
|
||||
}: SnippetInfoProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col', expand ? 'px-2 pb-1 pt-2' : 'p-1')}>
|
||||
<div className={cn('flex flex-col', expand ? 'gap-2 rounded-xl p-2' : '')}>
|
||||
<div className={cn('flex', expand ? 'items-center justify-between' : 'items-start gap-3')}>
|
||||
<div className={cn('shrink-0', !expand && 'ml-1')}>
|
||||
<AppIcon
|
||||
size={expand ? 'large' : 'small'}
|
||||
iconType="emoji"
|
||||
icon={snippet.icon}
|
||||
background={snippet.iconBackground}
|
||||
/>
|
||||
</div>
|
||||
{expand && <SnippetInfoDropdown snippet={snippet} />}
|
||||
</div>
|
||||
{expand && (
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-text-secondary system-md-semibold">
|
||||
{snippet.name}
|
||||
</div>
|
||||
<div className="pt-1 text-text-tertiary system-2xs-medium-uppercase">
|
||||
{t('typeLabel')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{expand && snippet.description && (
|
||||
<p className="line-clamp-3 break-words text-text-tertiary system-xs-regular">
|
||||
{snippet.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SnippetInfo)
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, fireEvent, screen } from '@testing-library/react'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
@@ -15,10 +15,13 @@ vi.mock('@/next/navigation', () => ({
|
||||
|
||||
const mockIsCurrentWorkspaceEditor = vi.fn(() => true)
|
||||
const mockIsCurrentWorkspaceDatasetOperator = vi.fn(() => false)
|
||||
const mockIsLoadingCurrentWorkspace = vi.fn(() => false)
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(),
|
||||
isCurrentWorkspaceDatasetOperator: mockIsCurrentWorkspaceDatasetOperator(),
|
||||
isLoadingCurrentWorkspace: mockIsLoadingCurrentWorkspace(),
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -36,6 +39,7 @@ const mockQueryState = {
|
||||
keywords: '',
|
||||
isCreatedByMe: false,
|
||||
}
|
||||
|
||||
vi.mock('../hooks/use-apps-query-state', () => ({
|
||||
default: () => ({
|
||||
query: mockQueryState,
|
||||
@@ -45,6 +49,7 @@ vi.mock('../hooks/use-apps-query-state', () => ({
|
||||
|
||||
let mockOnDSLFileDropped: ((file: File) => void) | null = null
|
||||
let mockDragging = false
|
||||
|
||||
vi.mock('../hooks/use-dsl-drag-drop', () => ({
|
||||
useDSLDragDrop: ({ onDSLFileDropped }: { onDSLFileDropped: (file: File) => void }) => {
|
||||
mockOnDSLFileDropped = onDSLFileDropped
|
||||
@@ -54,11 +59,13 @@ vi.mock('../hooks/use-dsl-drag-drop', () => ({
|
||||
|
||||
const mockRefetch = vi.fn()
|
||||
const mockFetchNextPage = vi.fn()
|
||||
const mockFetchSnippetNextPage = vi.fn()
|
||||
|
||||
const mockServiceState = {
|
||||
error: null as Error | null,
|
||||
hasNextPage: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
}
|
||||
|
||||
@@ -100,6 +107,7 @@ vi.mock('@/service/use-apps', () => ({
|
||||
useInfiniteAppList: () => ({
|
||||
data: defaultAppData,
|
||||
isLoading: mockServiceState.isLoading,
|
||||
isFetching: mockServiceState.isFetching,
|
||||
isFetchingNextPage: mockServiceState.isFetchingNextPage,
|
||||
fetchNextPage: mockFetchNextPage,
|
||||
hasNextPage: mockServiceState.hasNextPage,
|
||||
@@ -112,6 +120,57 @@ vi.mock('@/service/use-apps', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockSnippetServiceState = {
|
||||
error: null as Error | null,
|
||||
hasNextPage: false,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
}
|
||||
|
||||
const defaultSnippetData = {
|
||||
pages: [{
|
||||
data: [
|
||||
{
|
||||
id: 'snippet-1',
|
||||
name: 'Tone Rewriter',
|
||||
description: 'Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.',
|
||||
author: '',
|
||||
updatedAt: '2024-01-02 10:00',
|
||||
usage: '19',
|
||||
icon: '🪄',
|
||||
iconBackground: '#E0EAFF',
|
||||
status: undefined,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
}],
|
||||
}
|
||||
|
||||
vi.mock('@/service/use-snippets', () => ({
|
||||
useInfiniteSnippetList: () => ({
|
||||
data: defaultSnippetData,
|
||||
isLoading: mockSnippetServiceState.isLoading,
|
||||
isFetching: mockSnippetServiceState.isFetching,
|
||||
isFetchingNextPage: mockSnippetServiceState.isFetchingNextPage,
|
||||
fetchNextPage: mockFetchSnippetNextPage,
|
||||
hasNextPage: mockSnippetServiceState.hasNextPage,
|
||||
error: mockSnippetServiceState.error,
|
||||
}),
|
||||
useCreateSnippetMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useImportSnippetDSLMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useConfirmSnippetImportMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/tag', () => ({
|
||||
fetchTagList: vi.fn().mockResolvedValue([{ id: 'tag-1', name: 'Test Tag', type: 'app' }]),
|
||||
}))
|
||||
@@ -133,13 +192,21 @@ vi.mock('@/next/dynamic', () => ({
|
||||
return React.createElement('div', { 'data-testid': 'tag-management-modal' })
|
||||
}
|
||||
}
|
||||
|
||||
if (fnString.includes('create-from-dsl-modal')) {
|
||||
return function MockCreateFromDSLModal({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess: () => void }) {
|
||||
if (!show)
|
||||
return null
|
||||
return React.createElement('div', { 'data-testid': 'create-dsl-modal' }, React.createElement('button', { 'onClick': onClose, 'data-testid': 'close-dsl-modal' }, 'Close'), React.createElement('button', { 'onClick': onSuccess, 'data-testid': 'success-dsl-modal' }, 'Success'))
|
||||
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': 'create-dsl-modal' },
|
||||
React.createElement('button', { 'data-testid': 'close-dsl-modal', 'onClick': onClose }, 'Close'),
|
||||
React.createElement('button', { 'data-testid': 'success-dsl-modal', 'onClick': onSuccess }, 'Success'),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return () => null
|
||||
},
|
||||
}))
|
||||
@@ -188,9 +255,8 @@ beforeAll(() => {
|
||||
} as unknown as typeof IntersectionObserver
|
||||
})
|
||||
|
||||
// Render helper wrapping with shared nuqs testing helper.
|
||||
const renderList = (searchParams = '') => {
|
||||
return renderWithNuqs(<List />, { searchParams })
|
||||
const renderList = (props: React.ComponentProps<typeof List> = {}, searchParams = '') => {
|
||||
return renderWithNuqs(<List {...props} />, { searchParams })
|
||||
}
|
||||
|
||||
describe('List', () => {
|
||||
@@ -202,284 +268,62 @@ describe('List', () => {
|
||||
})
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(true)
|
||||
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
|
||||
mockIsLoadingCurrentWorkspace.mockReturnValue(false)
|
||||
mockDragging = false
|
||||
mockOnDSLFileDropped = null
|
||||
mockServiceState.error = null
|
||||
mockServiceState.hasNextPage = false
|
||||
mockServiceState.isLoading = false
|
||||
mockServiceState.isFetching = false
|
||||
mockServiceState.isFetchingNextPage = false
|
||||
mockQueryState.tagIDs = []
|
||||
mockQueryState.keywords = ''
|
||||
mockQueryState.isCreatedByMe = false
|
||||
mockSnippetServiceState.error = null
|
||||
mockSnippetServiceState.hasNextPage = false
|
||||
mockSnippetServiceState.isLoading = false
|
||||
mockSnippetServiceState.isFetching = false
|
||||
mockSnippetServiceState.isFetchingNextPage = false
|
||||
intersectionCallback = null
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tab slider with all app types', () => {
|
||||
describe('Apps Mode', () => {
|
||||
it('should render the apps route switch, dropdown filters, and app cards', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render search input', () => {
|
||||
renderList()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render tag filter', () => {
|
||||
renderList()
|
||||
expect(screen.getByRole('link', { name: 'app.studio.apps' })).toHaveAttribute('href', '/apps')
|
||||
expect(screen.getByRole('link', { name: 'workflow.tabs.snippets' })).toHaveAttribute('href', '/snippets')
|
||||
expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.studio.filters.creators')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render created by me checkbox', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app cards when apps exist', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render new app card for editors', () => {
|
||||
renderList()
|
||||
expect(screen.getByTestId('new-app-card')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render footer when branding is disabled', () => {
|
||||
renderList()
|
||||
expect(screen.getByTestId('footer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render drop DSL hint for editors', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tab Navigation', () => {
|
||||
it('should update URL when workflow tab is clicked', async () => {
|
||||
it('should update the category query when selecting an app type from the dropdown', async () => {
|
||||
const { onUrlUpdate } = renderList()
|
||||
|
||||
fireEvent.click(screen.getByText('app.types.workflow'))
|
||||
fireEvent.click(screen.getByText('app.studio.filters.types'))
|
||||
fireEvent.click(await screen.findByText('app.types.workflow'))
|
||||
|
||||
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(lastCall.searchParams.get('category')).toBe(AppModeEnum.WORKFLOW)
|
||||
})
|
||||
|
||||
it('should update URL when all tab is clicked', async () => {
|
||||
const { onUrlUpdate } = renderList('?category=workflow')
|
||||
|
||||
fireEvent.click(screen.getByText('app.types.all'))
|
||||
|
||||
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
// nuqs removes the default value ('all') from URL params
|
||||
expect(lastCall.searchParams.has('category')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Search Functionality', () => {
|
||||
it('should render search input field', () => {
|
||||
renderList()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle search input change', () => {
|
||||
it('should keep the creators dropdown visual-only and not update app query state', async () => {
|
||||
renderList()
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'test search' } })
|
||||
fireEvent.click(screen.getByText('app.studio.filters.creators'))
|
||||
fireEvent.click(await screen.findByText('Evan'))
|
||||
|
||||
expect(mockSetQuery).toHaveBeenCalled()
|
||||
expect(mockSetQuery).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('app.studio.filters.creators +1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle search clear button click', () => {
|
||||
mockQueryState.keywords = 'existing search'
|
||||
|
||||
renderList()
|
||||
|
||||
const clearButton = document.querySelector('.group')
|
||||
expect(clearButton).toBeInTheDocument()
|
||||
if (clearButton)
|
||||
fireEvent.click(clearButton)
|
||||
|
||||
expect(mockSetQuery).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tag Filter', () => {
|
||||
it('should render tag filter component', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Created By Me Filter', () => {
|
||||
it('should render checkbox with correct label', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle checkbox change', () => {
|
||||
renderList()
|
||||
|
||||
const checkbox = screen.getByTestId('checkbox-undefined')
|
||||
fireEvent.click(checkbox)
|
||||
|
||||
expect(mockSetQuery).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Non-Editor User', () => {
|
||||
it('should not render new app card for non-editors', () => {
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render drop DSL hint for non-editors', () => {
|
||||
mockIsCurrentWorkspaceEditor.mockReturnValue(false)
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.queryByText(/drop dsl file to create app/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dataset Operator Behavior', () => {
|
||||
it('should not trigger redirect at component level for dataset operators', () => {
|
||||
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(true)
|
||||
|
||||
renderList()
|
||||
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Local Storage Refresh', () => {
|
||||
it('should call refetch when refresh key is set in localStorage', () => {
|
||||
localStorage.setItem('needRefreshAppList', '1')
|
||||
|
||||
renderList()
|
||||
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
expect(localStorage.getItem('needRefreshAppList')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle multiple renders without issues', () => {
|
||||
const { rerender } = renderWithNuqs(<List />)
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
|
||||
rerender(<List />)
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render app cards correctly', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('Test App 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test App 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with all filter options visible', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.tag.placeholder')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Dragging State', () => {
|
||||
it('should show drop hint when DSL feature is enabled for editors', () => {
|
||||
renderList()
|
||||
expect(screen.getByText('app.newApp.dropDSLToCreateApp')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render dragging state overlay when dragging', () => {
|
||||
mockDragging = true
|
||||
const { container } = renderList()
|
||||
expect(container).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('App Type Tabs', () => {
|
||||
it('should render all app type tabs', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('app.types.all')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.workflow')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.advanced')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.chatbot')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.agent')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.types.completion')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update URL for each app type tab click', async () => {
|
||||
const { onUrlUpdate } = renderList()
|
||||
|
||||
const appTypeTexts = [
|
||||
{ mode: AppModeEnum.WORKFLOW, text: 'app.types.workflow' },
|
||||
{ mode: AppModeEnum.ADVANCED_CHAT, text: 'app.types.advanced' },
|
||||
{ mode: AppModeEnum.CHAT, text: 'app.types.chatbot' },
|
||||
{ mode: AppModeEnum.AGENT_CHAT, text: 'app.types.agent' },
|
||||
{ mode: AppModeEnum.COMPLETION, text: 'app.types.completion' },
|
||||
]
|
||||
|
||||
for (const { mode, text } of appTypeTexts) {
|
||||
onUrlUpdate.mockClear()
|
||||
fireEvent.click(screen.getByText(text))
|
||||
await vi.waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
|
||||
const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0]
|
||||
expect(lastCall.searchParams.get('category')).toBe(mode)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('App List Display', () => {
|
||||
it('should display all app cards from data', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByTestId('app-card-app-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('app-card-app-2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display app names correctly', () => {
|
||||
renderList()
|
||||
|
||||
expect(screen.getByText('Test App 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test App 2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Footer Visibility', () => {
|
||||
it('should render footer when branding is disabled', () => {
|
||||
renderList()
|
||||
expect(screen.getByTestId('footer')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DSL File Drop', () => {
|
||||
it('should handle DSL file drop and show modal', () => {
|
||||
it('should render and close the DSL import modal when a file is dropped', () => {
|
||||
renderList()
|
||||
|
||||
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
|
||||
@@ -489,98 +333,50 @@ describe('List', () => {
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close DSL modal when onClose is called', () => {
|
||||
renderList()
|
||||
|
||||
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
|
||||
act(() => {
|
||||
if (mockOnDSLFileDropped)
|
||||
mockOnDSLFileDropped(mockFile)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('close-dsl-modal'))
|
||||
|
||||
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should close DSL modal and refetch when onSuccess is called', () => {
|
||||
renderList()
|
||||
describe('Snippets Mode', () => {
|
||||
it('should render the snippets create card and snippet card from the real query hook', () => {
|
||||
renderList({ pageType: 'snippets' })
|
||||
|
||||
expect(screen.getByText('snippet.create')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tone Rewriter')).toBeInTheDocument()
|
||||
expect(screen.getByText('Rewrites rough drafts into a concise, professional tone for internal stakeholder updates.')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /Tone Rewriter/i })).toHaveAttribute('href', '/snippets/snippet-1/orchestrate')
|
||||
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('app-card-app-1')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should request the next snippet page when the infinite-scroll anchor intersects', () => {
|
||||
mockSnippetServiceState.hasNextPage = true
|
||||
renderList({ pageType: 'snippets' })
|
||||
|
||||
const mockFile = new File(['test content'], 'test.yml', { type: 'application/yaml' })
|
||||
act(() => {
|
||||
if (mockOnDSLFileDropped)
|
||||
mockOnDSLFileDropped(mockFile)
|
||||
intersectionCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('create-dsl-modal')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('success-dsl-modal'))
|
||||
|
||||
expect(screen.queryByTestId('create-dsl-modal')).not.toBeInTheDocument()
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Infinite Scroll', () => {
|
||||
it('should call fetchNextPage when intersection observer triggers', () => {
|
||||
mockServiceState.hasNextPage = true
|
||||
renderList()
|
||||
|
||||
if (intersectionCallback) {
|
||||
act(() => {
|
||||
intersectionCallback!(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
expect(mockFetchNextPage).toHaveBeenCalled()
|
||||
expect(mockFetchSnippetNextPage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call fetchNextPage when not intersecting', () => {
|
||||
mockServiceState.hasNextPage = true
|
||||
renderList()
|
||||
it('should not render app-only controls in snippets mode', () => {
|
||||
renderList({ pageType: 'snippets' })
|
||||
|
||||
if (intersectionCallback) {
|
||||
act(() => {
|
||||
intersectionCallback!(
|
||||
[{ isIntersecting: false } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
expect(mockFetchNextPage).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText('app.studio.filters.types')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.tag.placeholder')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('app.newApp.dropDSLToCreateApp')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not call fetchNextPage when loading', () => {
|
||||
mockServiceState.hasNextPage = true
|
||||
mockServiceState.isLoading = true
|
||||
renderList()
|
||||
it('should not fetch the next snippet page when no more data is available', () => {
|
||||
renderList({ pageType: 'snippets' })
|
||||
|
||||
if (intersectionCallback) {
|
||||
act(() => {
|
||||
intersectionCallback!(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
)
|
||||
})
|
||||
}
|
||||
act(() => {
|
||||
intersectionCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
|
||||
})
|
||||
|
||||
expect(mockFetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error State', () => {
|
||||
it('should handle error state in useEffect', () => {
|
||||
mockServiceState.error = new Error('Test error')
|
||||
const { container } = renderList()
|
||||
expect(container).toBeInTheDocument()
|
||||
expect(mockFetchSnippetNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
15
web/app/components/apps/app-type-filter-shared.ts
Normal file
15
web/app/components/apps/app-type-filter-shared.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { parseAsStringLiteral } from 'nuqs'
|
||||
import { AppModes } from '@/types/app'
|
||||
|
||||
export const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
|
||||
export type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
|
||||
|
||||
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
|
||||
|
||||
export const isAppListCategory = (value: string): value is AppListCategory => {
|
||||
return appListCategorySet.has(value)
|
||||
}
|
||||
|
||||
export const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
|
||||
.withDefault('all')
|
||||
.withOptions({ history: 'push' })
|
||||
71
web/app/components/apps/app-type-filter.tsx
Normal file
71
web/app/components/apps/app-type-filter.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuRadioItemIndicator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { isAppListCategory } from './app-type-filter-shared'
|
||||
|
||||
const chipClassName = 'flex h-8 items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-[13px] leading-[18px] text-text-secondary hover:bg-components-input-bg-hover'
|
||||
|
||||
type AppTypeFilterProps = {
|
||||
activeTab: import('./app-type-filter-shared').AppListCategory
|
||||
onChange: (value: import('./app-type-filter-shared').AppListCategory) => void
|
||||
}
|
||||
|
||||
const AppTypeFilter = ({
|
||||
activeTab,
|
||||
onChange,
|
||||
}: AppTypeFilterProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const options = useMemo(() => ([
|
||||
{ value: 'all', text: t('types.all', { ns: 'app' }), iconClassName: 'i-ri-apps-2-line' },
|
||||
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), iconClassName: 'i-ri-exchange-2-line' },
|
||||
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
|
||||
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), iconClassName: 'i-ri-message-3-line' },
|
||||
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), iconClassName: 'i-ri-robot-3-line' },
|
||||
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), iconClassName: 'i-ri-file-4-line' },
|
||||
]), [t])
|
||||
|
||||
const activeOption = options.find(option => option.value === activeTab)
|
||||
const triggerLabel = activeTab === 'all' ? t('studio.filters.types', { ns: 'app' }) : activeOption?.text
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className={cn(chipClassName, activeTab !== 'all' && 'shadow-xs')}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', activeOption?.iconClassName ?? 'i-ri-apps-2-line')} />
|
||||
<span>{triggerLabel}</span>
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-start" popupClassName="w-[220px]">
|
||||
<DropdownMenuRadioGroup value={activeTab} onValueChange={value => isAppListCategory(value) && onChange(value)}>
|
||||
{options.map(option => (
|
||||
<DropdownMenuRadioItem key={option.value} value={option.value}>
|
||||
<span aria-hidden className={cn('h-4 w-4 shrink-0 text-text-tertiary', option.iconClassName)} />
|
||||
<span>{option.text}</span>
|
||||
<DropdownMenuRadioItemIndicator />
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppTypeFilter
|
||||
128
web/app/components/apps/creators-filter.tsx
Normal file
128
web/app/components/apps/creators-filter.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuCheckboxItemIndicator,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type CreatorOption = {
|
||||
id: string
|
||||
name: string
|
||||
isYou?: boolean
|
||||
avatarClassName: string
|
||||
}
|
||||
|
||||
const chipClassName = 'flex h-8 items-center gap-1 rounded-lg border-[0.5px] border-transparent bg-components-input-bg-normal px-2 text-[13px] leading-[18px] text-text-secondary hover:bg-components-input-bg-hover'
|
||||
|
||||
const creatorOptions: CreatorOption[] = [
|
||||
{ id: 'evan', name: 'Evan', isYou: true, avatarClassName: 'bg-gradient-to-br from-[#ff9b3f] to-[#ff4d00]' },
|
||||
{ id: 'jack', name: 'Jack', avatarClassName: 'bg-gradient-to-br from-[#fde68a] to-[#d6d3d1]' },
|
||||
{ id: 'gigi', name: 'Gigi', avatarClassName: 'bg-gradient-to-br from-[#f9a8d4] to-[#a78bfa]' },
|
||||
{ id: 'alice', name: 'Alice', avatarClassName: 'bg-gradient-to-br from-[#93c5fd] to-[#4f46e5]' },
|
||||
{ id: 'mandy', name: 'Mandy', avatarClassName: 'bg-gradient-to-br from-[#374151] to-[#111827]' },
|
||||
]
|
||||
|
||||
const CreatorsFilter = () => {
|
||||
const { t } = useTranslation()
|
||||
const [selectedCreatorIds, setSelectedCreatorIds] = useState<string[]>([])
|
||||
const [keywords, setKeywords] = useState('')
|
||||
|
||||
const filteredCreators = useMemo(() => {
|
||||
const normalizedKeywords = keywords.trim().toLowerCase()
|
||||
if (!normalizedKeywords)
|
||||
return creatorOptions
|
||||
|
||||
return creatorOptions.filter(creator => creator.name.toLowerCase().includes(normalizedKeywords))
|
||||
}, [keywords])
|
||||
|
||||
const selectedCount = selectedCreatorIds.length
|
||||
const triggerLabel = selectedCount > 0
|
||||
? `${t('studio.filters.creators', { ns: 'app' })} +${selectedCount}`
|
||||
: t('studio.filters.creators', { ns: 'app' })
|
||||
|
||||
const toggleCreator = useCallback((creatorId: string) => {
|
||||
setSelectedCreatorIds((prev) => {
|
||||
if (prev.includes(creatorId))
|
||||
return prev.filter(id => id !== creatorId)
|
||||
return [...prev, creatorId]
|
||||
})
|
||||
}, [])
|
||||
|
||||
const resetCreators = useCallback(() => {
|
||||
setSelectedCreatorIds([])
|
||||
setKeywords('')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className={cn(chipClassName, selectedCount > 0 && 'border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs')}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-user-shared-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
<span>{triggerLabel}</span>
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-start" popupClassName="w-[280px] p-0">
|
||||
<div className="flex items-center gap-2 p-2 pb-1">
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
value={keywords}
|
||||
onChange={e => setKeywords(e.target.value)}
|
||||
onClear={() => setKeywords('')}
|
||||
placeholder={t('studio.filters.searchCreators', { ns: 'app' })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md px-2 py-1 text-xs font-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
onClick={resetCreators}
|
||||
>
|
||||
{t('studio.filters.reset', { ns: 'app' })}
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-1 pb-1">
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={selectedCreatorIds.length === 0}
|
||||
onCheckedChange={resetCreators}
|
||||
>
|
||||
<span aria-hidden className="i-ri-user-line h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
<span>{t('studio.filters.allCreators', { ns: 'app' })}</span>
|
||||
<DropdownMenuCheckboxItemIndicator />
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
{filteredCreators.map(creator => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={creator.id}
|
||||
checked={selectedCreatorIds.includes(creator.id)}
|
||||
onCheckedChange={() => toggleCreator(creator.id)}
|
||||
>
|
||||
<span className={cn('h-5 w-5 shrink-0 rounded-full border border-white', creator.avatarClassName)} />
|
||||
<span className="flex min-w-0 grow items-center justify-between gap-2">
|
||||
<span className="truncate">{creator.name}</span>
|
||||
{creator.isYou && (
|
||||
<span className="shrink-0 text-text-quaternary">{t('studio.filters.you', { ns: 'app' })}</span>
|
||||
)}
|
||||
</span>
|
||||
<DropdownMenuCheckboxItemIndicator />
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreatorsFilter
|
||||
@@ -12,14 +12,24 @@ import dynamic from '@/next/dynamic'
|
||||
import { fetchAppDetail } from '@/service/explore'
|
||||
import List from './list'
|
||||
|
||||
export type StudioPageType = 'apps' | 'snippets'
|
||||
|
||||
type AppsProps = {
|
||||
pageType?: StudioPageType
|
||||
}
|
||||
|
||||
const DSLConfirmModal = dynamic(() => import('../app/create-from-dsl-modal/dsl-confirm-modal'), { ssr: false })
|
||||
const CreateAppModal = dynamic(() => import('../explore/create-app-modal'), { ssr: false })
|
||||
const TryApp = dynamic(() => import('../explore/try-app'), { ssr: false })
|
||||
|
||||
const Apps = () => {
|
||||
const Apps = ({
|
||||
pageType = 'apps',
|
||||
}: AppsProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
useDocumentTitle(t('menus.apps', { ns: 'common' }))
|
||||
useDocumentTitle(pageType === 'apps'
|
||||
? t('menus.apps', { ns: 'common' })
|
||||
: t('tabs.snippets', { ns: 'workflow' }))
|
||||
useEducationInit()
|
||||
|
||||
const [currentTryAppParams, setCurrentTryAppParams] = useState<TryAppSelection | undefined>(undefined)
|
||||
@@ -103,7 +113,7 @@ const Apps = () => {
|
||||
}}
|
||||
>
|
||||
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
|
||||
<List controlRefreshList={controlRefreshList} />
|
||||
<List controlRefreshList={controlRefreshList} pageType={pageType} />
|
||||
{isShowTryAppPanel && (
|
||||
<TryApp
|
||||
appId={currentTryAppParams?.appId || ''}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { StudioPageType } from '.'
|
||||
import type { App } from '@/types/app'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Input from '@/app/components/base/input'
|
||||
import TabSliderNew from '@/app/components/base/tab-slider-new'
|
||||
import TagFilter from '@/app/components/base/tag-management/filter'
|
||||
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
@@ -16,15 +16,21 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { CheckModal } from '@/hooks/use-pay'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { useInfiniteAppList } from '@/service/use-apps'
|
||||
import { AppModeEnum, AppModes } from '@/types/app'
|
||||
import { useInfiniteSnippetList } from '@/service/use-snippets'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import SnippetCard from '../snippets/components/snippet-card'
|
||||
import SnippetCreateCard from '../snippets/components/snippet-create-card'
|
||||
import AppCard from './app-card'
|
||||
import { AppCardSkeleton } from './app-card-skeleton'
|
||||
import AppTypeFilter from './app-type-filter'
|
||||
import { parseAsAppListCategory } from './app-type-filter-shared'
|
||||
import CreatorsFilter from './creators-filter'
|
||||
import Empty from './empty'
|
||||
import Footer from './footer'
|
||||
import useAppsQueryState from './hooks/use-apps-query-state'
|
||||
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
|
||||
import NewAppCard from './new-app-card'
|
||||
import StudioRouteSwitch from './studio-route-switch'
|
||||
|
||||
const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), {
|
||||
ssr: false,
|
||||
@@ -33,25 +39,17 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
const APP_LIST_CATEGORY_VALUES = ['all', ...AppModes] as const
|
||||
type AppListCategory = typeof APP_LIST_CATEGORY_VALUES[number]
|
||||
const appListCategorySet = new Set<string>(APP_LIST_CATEGORY_VALUES)
|
||||
|
||||
const isAppListCategory = (value: string): value is AppListCategory => {
|
||||
return appListCategorySet.has(value)
|
||||
}
|
||||
|
||||
const parseAsAppListCategory = parseAsStringLiteral(APP_LIST_CATEGORY_VALUES)
|
||||
.withDefault('all')
|
||||
.withOptions({ history: 'push' })
|
||||
|
||||
type Props = {
|
||||
controlRefreshList?: number
|
||||
pageType?: StudioPageType
|
||||
}
|
||||
|
||||
const List: FC<Props> = ({
|
||||
controlRefreshList = 0,
|
||||
pageType = 'apps',
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isAppsPage = pageType === 'apps'
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
|
||||
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
|
||||
@@ -61,18 +59,22 @@ const List: FC<Props> = ({
|
||||
)
|
||||
|
||||
const { query: { tagIDs = [], keywords = '', isCreatedByMe: queryIsCreatedByMe = false }, setQuery } = useAppsQueryState()
|
||||
const [isCreatedByMe, setIsCreatedByMe] = useState(queryIsCreatedByMe)
|
||||
const [tagFilterValue, setTagFilterValue] = useState<string[]>(tagIDs)
|
||||
const [searchKeywords, setSearchKeywords] = useState(keywords)
|
||||
const newAppCardRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [appKeywords, setAppKeywords] = useState(keywords)
|
||||
const [snippetKeywordsInput, setSnippetKeywordsInput] = useState('')
|
||||
const [snippetKeywords, setSnippetKeywords] = useState('')
|
||||
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
|
||||
const [droppedDSLFile, setDroppedDSLFile] = useState<File | undefined>()
|
||||
const setKeywords = useCallback((keywords: string) => {
|
||||
setQuery(prev => ({ ...prev, keywords }))
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const newAppCardRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const setKeywords = useCallback((nextKeywords: string) => {
|
||||
setQuery(prev => ({ ...prev, keywords: nextKeywords }))
|
||||
}, [setQuery])
|
||||
const setTagIDs = useCallback((tagIDs: string[]) => {
|
||||
setQuery(prev => ({ ...prev, tagIDs }))
|
||||
|
||||
const setTagIDs = useCallback((nextTagIDs: string[]) => {
|
||||
setQuery(prev => ({ ...prev, tagIDs: nextTagIDs }))
|
||||
}, [setQuery])
|
||||
|
||||
const handleDSLFileDropped = useCallback((file: File) => {
|
||||
@@ -83,15 +85,15 @@ const List: FC<Props> = ({
|
||||
const { dragging } = useDSLDragDrop({
|
||||
onDSLFileDropped: handleDSLFileDropped,
|
||||
containerRef,
|
||||
enabled: isCurrentWorkspaceEditor,
|
||||
enabled: isAppsPage && isCurrentWorkspaceEditor,
|
||||
})
|
||||
|
||||
const appListQueryParams = {
|
||||
page: 1,
|
||||
limit: 30,
|
||||
name: searchKeywords,
|
||||
name: appKeywords,
|
||||
tag_ids: tagIDs,
|
||||
is_created_by_me: isCreatedByMe,
|
||||
is_created_by_me: queryIsCreatedByMe,
|
||||
...(activeTab !== 'all' ? { mode: activeTab } : {}),
|
||||
}
|
||||
|
||||
@@ -104,159 +106,214 @@ const List: FC<Props> = ({
|
||||
hasNextPage,
|
||||
error,
|
||||
refetch,
|
||||
} = useInfiniteAppList(appListQueryParams, { enabled: !isCurrentWorkspaceDatasetOperator })
|
||||
} = useInfiniteAppList(appListQueryParams, {
|
||||
enabled: isAppsPage && !isCurrentWorkspaceDatasetOperator,
|
||||
})
|
||||
|
||||
const {
|
||||
data: snippetData,
|
||||
isLoading: isSnippetListLoading,
|
||||
isFetching: isSnippetListFetching,
|
||||
isFetchingNextPage: isSnippetListFetchingNextPage,
|
||||
fetchNextPage: fetchSnippetNextPage,
|
||||
hasNextPage: hasSnippetNextPage,
|
||||
error: snippetError,
|
||||
} = useInfiniteSnippetList({
|
||||
page: 1,
|
||||
limit: 30,
|
||||
keyword: snippetKeywords || undefined,
|
||||
}, {
|
||||
enabled: !isAppsPage,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (controlRefreshList > 0) {
|
||||
if (isAppsPage && controlRefreshList > 0)
|
||||
refetch()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [controlRefreshList])
|
||||
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const options = [
|
||||
{ value: 'all', text: t('types.all', { ns: 'app' }), icon: <span className="i-ri-apps-2-line mr-1 h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.WORKFLOW, text: t('types.workflow', { ns: 'app' }), icon: <span className="i-ri-exchange-2-line mr-1 h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.ADVANCED_CHAT, text: t('types.advanced', { ns: 'app' }), icon: <span className="i-ri-message-3-line mr-1 h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.CHAT, text: t('types.chatbot', { ns: 'app' }), icon: <span className="i-ri-message-3-line mr-1 h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.AGENT_CHAT, text: t('types.agent', { ns: 'app' }), icon: <span className="i-ri-robot-3-line mr-1 h-[14px] w-[14px]" /> },
|
||||
{ value: AppModeEnum.COMPLETION, text: t('types.completion', { ns: 'app' }), icon: <span className="i-ri-file-4-line mr-1 h-[14px] w-[14px]" /> },
|
||||
]
|
||||
}, [controlRefreshList, isAppsPage, refetch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAppsPage)
|
||||
return
|
||||
|
||||
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
|
||||
localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY)
|
||||
refetch()
|
||||
}
|
||||
}, [refetch])
|
||||
}, [isAppsPage, refetch])
|
||||
|
||||
useEffect(() => {
|
||||
if (isCurrentWorkspaceDatasetOperator)
|
||||
return
|
||||
const hasMore = hasNextPage ?? true
|
||||
|
||||
const hasMore = isAppsPage ? (hasNextPage ?? true) : (hasSnippetNextPage ?? true)
|
||||
const isPageLoading = isAppsPage ? isLoading : isSnippetListLoading
|
||||
const isNextPageFetching = isAppsPage ? isFetchingNextPage : isSnippetListFetchingNextPage
|
||||
const currentError = isAppsPage ? error : snippetError
|
||||
let observer: IntersectionObserver | undefined
|
||||
|
||||
if (error) {
|
||||
if (observer)
|
||||
observer.disconnect()
|
||||
if (currentError) {
|
||||
observer?.disconnect()
|
||||
return
|
||||
}
|
||||
|
||||
if (anchorRef.current && containerRef.current) {
|
||||
// Calculate dynamic rootMargin: clamps to 100-200px range, using 20% of container height as the base value for better responsiveness
|
||||
const containerHeight = containerRef.current.clientHeight
|
||||
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) // Clamps to 100-200px range, using 20% of container height as the base value
|
||||
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200))
|
||||
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore)
|
||||
fetchNextPage()
|
||||
if (entries[0].isIntersecting && !isPageLoading && !isNextPageFetching && !currentError && hasMore) {
|
||||
if (isAppsPage)
|
||||
fetchNextPage()
|
||||
else
|
||||
fetchSnippetNextPage()
|
||||
}
|
||||
}, {
|
||||
root: containerRef.current,
|
||||
rootMargin: `${dynamicMargin}px`,
|
||||
threshold: 0.1, // Trigger when 10% of the anchor element is visible
|
||||
threshold: 0.1,
|
||||
})
|
||||
observer.observe(anchorRef.current)
|
||||
}
|
||||
|
||||
return () => observer?.disconnect()
|
||||
}, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
|
||||
}, [error, fetchNextPage, fetchSnippetNextPage, hasNextPage, hasSnippetNextPage, isAppsPage, isCurrentWorkspaceDatasetOperator, isFetchingNextPage, isLoading, isSnippetListFetchingNextPage, isSnippetListLoading, snippetError])
|
||||
|
||||
const { run: handleSearch } = useDebounceFn(() => {
|
||||
setSearchKeywords(keywords)
|
||||
const { run: handleAppSearch } = useDebounceFn((value: string) => {
|
||||
setAppKeywords(value)
|
||||
}, { wait: 500 })
|
||||
const handleKeywordsChange = (value: string) => {
|
||||
setKeywords(value)
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
const { run: handleTagsUpdate } = useDebounceFn(() => {
|
||||
setTagIDs(tagFilterValue)
|
||||
const { run: handleSnippetSearch } = useDebounceFn((value: string) => {
|
||||
setSnippetKeywords(value)
|
||||
}, { wait: 500 })
|
||||
const handleTagsChange = (value: string[]) => {
|
||||
|
||||
const handleKeywordsChange = useCallback((value: string) => {
|
||||
if (isAppsPage) {
|
||||
setKeywords(value)
|
||||
handleAppSearch(value)
|
||||
return
|
||||
}
|
||||
|
||||
setSnippetKeywordsInput(value)
|
||||
handleSnippetSearch(value)
|
||||
}, [handleAppSearch, handleSnippetSearch, isAppsPage, setKeywords])
|
||||
|
||||
const { run: handleTagsUpdate } = useDebounceFn((value: string[]) => {
|
||||
setTagIDs(value)
|
||||
}, { wait: 500 })
|
||||
|
||||
const handleTagsChange = useCallback((value: string[]) => {
|
||||
setTagFilterValue(value)
|
||||
handleTagsUpdate()
|
||||
}
|
||||
handleTagsUpdate(value)
|
||||
}, [handleTagsUpdate])
|
||||
|
||||
const handleCreatedByMeChange = useCallback(() => {
|
||||
const newValue = !isCreatedByMe
|
||||
setIsCreatedByMe(newValue)
|
||||
setQuery(prev => ({ ...prev, isCreatedByMe: newValue }))
|
||||
}, [isCreatedByMe, setQuery])
|
||||
const appItems = useMemo<App[]>(() => {
|
||||
return (data?.pages ?? []).flatMap(({ data: apps }) => apps)
|
||||
}, [data?.pages])
|
||||
|
||||
const pages = data?.pages ?? []
|
||||
const hasAnyApp = (pages[0]?.total ?? 0) > 0
|
||||
// Show skeleton during initial load or when refetching with no previous data
|
||||
const showSkeleton = isLoading || (isFetching && pages.length === 0)
|
||||
const snippetItems = useMemo(() => {
|
||||
return (snippetData?.pages ?? []).flatMap(({ data }) => data)
|
||||
}, [snippetData?.pages])
|
||||
|
||||
const showSkeleton = isAppsPage
|
||||
? (isLoading || (isFetching && data?.pages?.length === 0))
|
||||
: (isSnippetListLoading || (isSnippetListFetching && snippetItems.length === 0))
|
||||
const hasAnyApp = (data?.pages?.[0]?.total ?? 0) > 0
|
||||
const hasAnySnippet = snippetItems.length > 0
|
||||
const currentKeywords = isAppsPage ? keywords : snippetKeywordsInput
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
|
||||
{dragging && (
|
||||
<div className="absolute inset-0 z-50 m-0.5 rounded-2xl border-2 border-dashed border-components-dropzone-border-accent bg-[rgba(21,90,239,0.14)] p-2">
|
||||
</div>
|
||||
<div className="absolute inset-0 z-50 m-0.5 rounded-2xl border-2 border-dashed border-components-dropzone-border-accent bg-[rgba(21,90,239,0.14)] p-2" />
|
||||
)}
|
||||
|
||||
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-y-2 bg-background-body px-12 pb-5 pt-7">
|
||||
<TabSliderNew
|
||||
value={activeTab}
|
||||
onChange={(nextValue) => {
|
||||
if (isAppListCategory(nextValue))
|
||||
setActiveTab(nextValue)
|
||||
}}
|
||||
options={options}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StudioRouteSwitch
|
||||
pageType={pageType}
|
||||
appsLabel={t('studio.apps', { ns: 'app' })}
|
||||
snippetsLabel={t('tabs.snippets', { ns: 'workflow' })}
|
||||
/>
|
||||
{isAppsPage && (
|
||||
<AppTypeFilter
|
||||
activeTab={activeTab}
|
||||
onChange={(value) => {
|
||||
void setActiveTab(value)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CreatorsFilter />
|
||||
{isAppsPage && (
|
||||
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="mr-2 flex h-7 items-center space-x-2">
|
||||
<Checkbox checked={isCreatedByMe} onCheck={handleCreatedByMeChange} />
|
||||
<div className="text-sm font-normal text-text-secondary">
|
||||
{t('showMyCreatedAppsOnly', { ns: 'app' })}
|
||||
</div>
|
||||
</label>
|
||||
<TagFilter type="app" value={tagFilterValue} onChange={handleTagsChange} />
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
wrapperClassName="w-[200px]"
|
||||
value={keywords}
|
||||
placeholder={isAppsPage ? undefined : t('tabs.searchSnippets', { ns: 'workflow' })}
|
||||
value={currentKeywords}
|
||||
onChange={e => handleKeywordsChange(e.target.value)}
|
||||
onClear={() => handleKeywordsChange('')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
'relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6',
|
||||
!hasAnyApp && 'overflow-hidden',
|
||||
isAppsPage && !hasAnyApp && 'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{(isCurrentWorkspaceEditor || isLoadingCurrentWorkspace) && (
|
||||
<NewAppCard
|
||||
ref={newAppCardRef}
|
||||
isLoading={isLoadingCurrentWorkspace}
|
||||
onSuccess={refetch}
|
||||
selectedAppType={activeTab}
|
||||
className={cn(!hasAnyApp && 'z-10')}
|
||||
/>
|
||||
isAppsPage
|
||||
? (
|
||||
<NewAppCard
|
||||
ref={newAppCardRef}
|
||||
isLoading={isLoadingCurrentWorkspace}
|
||||
onSuccess={refetch}
|
||||
selectedAppType={activeTab}
|
||||
className={cn(!hasAnyApp && 'z-10')}
|
||||
/>
|
||||
)
|
||||
: <SnippetCreateCard />
|
||||
)}
|
||||
{(() => {
|
||||
if (showSkeleton)
|
||||
return <AppCardSkeleton count={6} />
|
||||
|
||||
if (hasAnyApp) {
|
||||
return pages.flatMap(({ data: apps }) => apps).map(app => (
|
||||
<AppCard key={app.id} app={app} onRefresh={refetch} />
|
||||
))
|
||||
}
|
||||
{showSkeleton && <AppCardSkeleton count={6} />}
|
||||
|
||||
// No apps - show empty state
|
||||
return <Empty />
|
||||
})()}
|
||||
{isFetchingNextPage && (
|
||||
{!showSkeleton && isAppsPage && hasAnyApp && appItems.map(app => (
|
||||
<AppCard key={app.id} app={app} onRefresh={refetch} />
|
||||
))}
|
||||
|
||||
{!showSkeleton && !isAppsPage && hasAnySnippet && snippetItems.map(snippet => (
|
||||
<SnippetCard key={snippet.id} snippet={snippet} />
|
||||
))}
|
||||
|
||||
{!showSkeleton && isAppsPage && !hasAnyApp && <Empty />}
|
||||
|
||||
{!showSkeleton && !isAppsPage && !hasAnySnippet && (
|
||||
<div className="col-span-full flex min-h-[240px] items-center justify-center rounded-xl border border-dashed border-divider-regular bg-components-card-bg p-6 text-center text-sm text-text-tertiary">
|
||||
{t('tabs.noSnippetsFound', { ns: 'workflow' })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAppsPage && isFetchingNextPage && (
|
||||
<AppCardSkeleton count={3} />
|
||||
)}
|
||||
|
||||
{!isAppsPage && isSnippetListFetchingNextPage && (
|
||||
<AppCardSkeleton count={3} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCurrentWorkspaceEditor && (
|
||||
{isAppsPage && isCurrentWorkspaceEditor && (
|
||||
<div
|
||||
className={`flex items-center justify-center gap-2 py-4 ${dragging ? 'text-text-accent' : 'text-text-quaternary'}`}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 py-4',
|
||||
dragging ? 'text-text-accent' : 'text-text-quaternary',
|
||||
)}
|
||||
role="region"
|
||||
aria-label={t('newApp.dropDSLToCreateApp', { ns: 'app' })}
|
||||
>
|
||||
@@ -264,17 +321,18 @@ const List: FC<Props> = ({
|
||||
<span className="system-xs-regular">{t('newApp.dropDSLToCreateApp', { ns: 'app' })}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!systemFeatures.branding.enabled && (
|
||||
<Footer />
|
||||
)}
|
||||
<CheckModal />
|
||||
<div ref={anchorRef} className="h-0"> </div>
|
||||
{showTagManagementModal && (
|
||||
{isAppsPage && showTagManagementModal && (
|
||||
<TagManagementModal type="app" show={showTagManagementModal} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCreateFromDSLModal && (
|
||||
{isAppsPage && showCreateFromDSLModal && (
|
||||
<CreateFromDSLModal
|
||||
show={showCreateFromDSLModal}
|
||||
onClose={() => {
|
||||
|
||||
44
web/app/components/apps/studio-route-switch.tsx
Normal file
44
web/app/components/apps/studio-route-switch.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import type { StudioPageType } from '.'
|
||||
import Link from '@/next/link'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
pageType: StudioPageType
|
||||
appsLabel: string
|
||||
snippetsLabel: string
|
||||
}
|
||||
|
||||
const StudioRouteSwitch = ({
|
||||
pageType,
|
||||
appsLabel,
|
||||
snippetsLabel,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="flex items-center rounded-lg border-[0.5px] border-divider-subtle bg-[rgba(200,206,218,0.2)] p-[1px]">
|
||||
<Link
|
||||
href="/apps"
|
||||
className={cn(
|
||||
'flex h-8 items-center rounded-lg px-3 text-[14px] leading-5 text-text-secondary',
|
||||
pageType === 'apps' && 'bg-components-card-bg font-semibold text-text-primary shadow-xs',
|
||||
pageType !== 'apps' && 'font-medium',
|
||||
)}
|
||||
>
|
||||
{appsLabel}
|
||||
</Link>
|
||||
<Link
|
||||
href="/snippets"
|
||||
className={cn(
|
||||
'flex h-8 items-center rounded-lg px-3 text-[14px] leading-5 text-text-secondary',
|
||||
pageType === 'snippets' && 'bg-components-card-bg font-semibold text-text-primary shadow-xs',
|
||||
pageType !== 'snippets' && 'font-medium',
|
||||
)}
|
||||
>
|
||||
{snippetsLabel}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StudioRouteSwitch
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.33317 3.33333H7.33317V12.6667H5.33317V14H10.6665V12.6667H8.6665V3.33333H10.6665V2H5.33317V3.33333ZM1.33317 4.66667C0.964984 4.66667 0.666504 4.96515 0.666504 5.33333V10.6667C0.666504 11.0349 0.964984 11.3333 1.33317 11.3333H5.33317V10H1.99984V6H5.33317V4.66667H1.33317ZM10.6665 6H13.9998V10H10.6665V11.3333H14.6665C15.0347 11.3333 15.3332 11.0349 15.3332 10.6667V5.33333C15.3332 4.96515 15.0347 4.66667 14.6665 4.66667H10.6665V6Z" fill="#354052"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 563 B |
211
web/app/components/evaluation/__tests__/index.spec.tsx
Normal file
211
web/app/components/evaluation/__tests__/index.spec.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import Evaluation from '..'
|
||||
import { getEvaluationMockConfig } from '../mock'
|
||||
import { useEvaluationStore } from '../store'
|
||||
|
||||
const mockUseAvailableEvaluationMetrics = vi.hoisted(() => vi.fn())
|
||||
const mockUseEvaluationNodeInfoMutation = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useModelList: () => ({
|
||||
data: [{
|
||||
provider: 'openai',
|
||||
models: [{ model: 'gpt-4o-mini' }],
|
||||
}],
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
|
||||
default: ({ defaultModel }: { defaultModel?: { provider: string, model: string } }) => (
|
||||
<div data-testid="evaluation-model-selector">
|
||||
{defaultModel ? `${defaultModel.provider}:${defaultModel.model}` : 'empty'}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-evaluation', () => ({
|
||||
useAvailableEvaluationMetrics: (...args: unknown[]) => mockUseAvailableEvaluationMetrics(...args),
|
||||
useEvaluationNodeInfoMutation: (...args: unknown[]) => mockUseEvaluationNodeInfoMutation(...args),
|
||||
}))
|
||||
|
||||
describe('Evaluation', () => {
|
||||
beforeEach(() => {
|
||||
useEvaluationStore.setState({ resources: {} })
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockUseAvailableEvaluationMetrics.mockReturnValue({
|
||||
data: {
|
||||
metrics: ['answer-correctness', 'faithfulness'],
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
mockUseEvaluationNodeInfoMutation.mockReturnValue({
|
||||
isPending: false,
|
||||
mutate: (_input: unknown, options?: { onSuccess?: (data: Record<string, Array<{ node_id: string, title: string, type: string }>>) => void }) => {
|
||||
options?.onSuccess?.({
|
||||
'answer-correctness': [
|
||||
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
|
||||
],
|
||||
'faithfulness': [
|
||||
{ node_id: 'node-faithfulness', title: 'Retriever Node', type: 'retriever' },
|
||||
],
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should search, select metric nodes, and create a batch history record', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
render(<Evaluation resourceType="workflow" resourceId="app-1" />)
|
||||
|
||||
expect(screen.getByTestId('evaluation-model-selector')).toHaveTextContent('openai:gpt-4o-mini')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchNodeOrMetrics'), {
|
||||
target: { value: 'does-not-exist' },
|
||||
})
|
||||
|
||||
expect(screen.getByText('evaluation.metrics.noResults')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchNodeOrMetrics'), {
|
||||
target: { value: 'faith' },
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('evaluation-metric-node-faithfulness-node-faithfulness'))
|
||||
expect(screen.getAllByText('Faithfulness').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Retriever Node').length).toBeGreaterThan(0)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('evaluation.metrics.searchNodeOrMetrics'), {
|
||||
target: { value: '' },
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('evaluation-metric-node-answer-correctness-node-answer'))
|
||||
expect(screen.getAllByText('Answer Correctness').length).toBeGreaterThan(0)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'evaluation.batch.run' }))
|
||||
expect(screen.getByText('evaluation.batch.status.running')).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1300)
|
||||
})
|
||||
|
||||
expect(screen.getByText('evaluation.batch.status.success')).toBeInTheDocument()
|
||||
expect(screen.getByText('Workflow evaluation batch')).toBeInTheDocument()
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should render time placeholders and hide the value row for empty operators', () => {
|
||||
const resourceType = 'workflow'
|
||||
const resourceId = 'app-2'
|
||||
const store = useEvaluationStore.getState()
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
|
||||
const timeField = config.fieldOptions.find(field => field.type === 'time')!
|
||||
let groupId = ''
|
||||
let itemId = ''
|
||||
|
||||
act(() => {
|
||||
store.ensureResource(resourceType, resourceId)
|
||||
store.setJudgeModel(resourceType, resourceId, 'openai::gpt-4o-mini')
|
||||
|
||||
const group = useEvaluationStore.getState().resources['workflow:app-2'].conditions[0]
|
||||
groupId = group.id
|
||||
itemId = group.items[0].id
|
||||
|
||||
store.updateConditionField(resourceType, resourceId, groupId, itemId, timeField.id)
|
||||
store.updateConditionOperator(resourceType, resourceId, groupId, itemId, 'before')
|
||||
})
|
||||
|
||||
let rerender: ReturnType<typeof render>['rerender']
|
||||
act(() => {
|
||||
({ rerender } = render(<Evaluation resourceType={resourceType} resourceId={resourceId} />))
|
||||
})
|
||||
|
||||
expect(screen.getByText('evaluation.conditions.selectTime')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
store.updateConditionOperator(resourceType, resourceId, groupId, itemId, 'is_empty')
|
||||
rerender(<Evaluation resourceType={resourceType} resourceId={resourceId} />)
|
||||
})
|
||||
|
||||
expect(screen.queryByText('evaluation.conditions.selectTime')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the metric no-node empty state', () => {
|
||||
mockUseAvailableEvaluationMetrics.mockReturnValue({
|
||||
data: {
|
||||
metrics: ['context-precision'],
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
mockUseEvaluationNodeInfoMutation.mockReturnValue({
|
||||
isPending: false,
|
||||
mutate: (_input: unknown, options?: { onSuccess?: (data: Record<string, Array<{ node_id: string, title: string, type: string }>>) => void }) => {
|
||||
options?.onSuccess?.({
|
||||
'context-precision': [],
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
render(<Evaluation resourceType="workflow" resourceId="app-3" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
|
||||
|
||||
expect(screen.getByText('evaluation.metrics.noNodesInWorkflow')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the global empty state when no metrics are available', () => {
|
||||
mockUseAvailableEvaluationMetrics.mockReturnValue({
|
||||
data: {
|
||||
metrics: [],
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<Evaluation resourceType="workflow" resourceId="app-4" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
|
||||
|
||||
expect(screen.getByText('evaluation.metrics.noResults')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show more nodes when a metric has more than three nodes', () => {
|
||||
mockUseAvailableEvaluationMetrics.mockReturnValue({
|
||||
data: {
|
||||
metrics: ['answer-correctness'],
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
mockUseEvaluationNodeInfoMutation.mockReturnValue({
|
||||
isPending: false,
|
||||
mutate: (_input: unknown, options?: { onSuccess?: (data: Record<string, Array<{ node_id: string, title: string, type: string }>>) => void }) => {
|
||||
options?.onSuccess?.({
|
||||
'answer-correctness': [
|
||||
{ node_id: 'node-1', title: 'LLM 1', type: 'llm' },
|
||||
{ node_id: 'node-2', title: 'LLM 2', type: 'llm' },
|
||||
{ node_id: 'node-3', title: 'LLM 3', type: 'llm' },
|
||||
{ node_id: 'node-4', title: 'LLM 4', type: 'llm' },
|
||||
],
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
render(<Evaluation resourceType="workflow" resourceId="app-5" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.add' }))
|
||||
|
||||
expect(screen.getByText('LLM 3')).toBeInTheDocument()
|
||||
expect(screen.queryByText('LLM 4')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.showMore' }))
|
||||
|
||||
expect(screen.getByText('LLM 4')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'evaluation.metrics.showLess' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
120
web/app/components/evaluation/__tests__/store.spec.ts
Normal file
120
web/app/components/evaluation/__tests__/store.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { getEvaluationMockConfig } from '../mock'
|
||||
import {
|
||||
getAllowedOperators,
|
||||
isCustomMetricConfigured,
|
||||
requiresConditionValue,
|
||||
useEvaluationStore,
|
||||
} from '../store'
|
||||
|
||||
describe('evaluation store', () => {
|
||||
beforeEach(() => {
|
||||
useEvaluationStore.setState({ resources: {} })
|
||||
})
|
||||
|
||||
it('should configure a custom metric mapping to a valid state', () => {
|
||||
const resourceType = 'workflow'
|
||||
const resourceId = 'app-1'
|
||||
const store = useEvaluationStore.getState()
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
|
||||
store.ensureResource(resourceType, resourceId)
|
||||
store.addCustomMetric(resourceType, resourceId)
|
||||
|
||||
const initialMetric = useEvaluationStore.getState().resources['workflow:app-1'].metrics.find(metric => metric.kind === 'custom-workflow')
|
||||
expect(initialMetric).toBeDefined()
|
||||
expect(isCustomMetricConfigured(initialMetric!)).toBe(false)
|
||||
|
||||
store.setCustomMetricWorkflow(resourceType, resourceId, initialMetric!.id, config.workflowOptions[0].id)
|
||||
store.updateCustomMetricMapping(resourceType, resourceId, initialMetric!.id, initialMetric!.customConfig!.mappings[0].id, {
|
||||
sourceFieldId: config.fieldOptions[0].id,
|
||||
targetVariableId: config.workflowOptions[0].targetVariables[0].id,
|
||||
})
|
||||
|
||||
const configuredMetric = useEvaluationStore.getState().resources['workflow:app-1'].metrics.find(metric => metric.id === initialMetric!.id)
|
||||
expect(isCustomMetricConfigured(configuredMetric!)).toBe(true)
|
||||
})
|
||||
|
||||
it('should add and remove builtin metrics', () => {
|
||||
const resourceType = 'workflow'
|
||||
const resourceId = 'app-2'
|
||||
const store = useEvaluationStore.getState()
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
|
||||
store.ensureResource(resourceType, resourceId)
|
||||
store.addBuiltinMetric(resourceType, resourceId, config.builtinMetrics[1].id)
|
||||
|
||||
const addedMetric = useEvaluationStore.getState().resources['workflow:app-2'].metrics.find(metric => metric.optionId === config.builtinMetrics[1].id)
|
||||
expect(addedMetric).toBeDefined()
|
||||
|
||||
store.removeMetric(resourceType, resourceId, addedMetric!.id)
|
||||
|
||||
expect(useEvaluationStore.getState().resources['workflow:app-2'].metrics.some(metric => metric.id === addedMetric!.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('should upsert builtin metric node selections', () => {
|
||||
const resourceType = 'workflow'
|
||||
const resourceId = 'app-4'
|
||||
const store = useEvaluationStore.getState()
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
const metricId = config.builtinMetrics[0].id
|
||||
|
||||
store.ensureResource(resourceType, resourceId)
|
||||
store.addBuiltinMetric(resourceType, resourceId, metricId, [
|
||||
{ node_id: 'node-1', title: 'Answer Node', type: 'answer' },
|
||||
])
|
||||
|
||||
store.addBuiltinMetric(resourceType, resourceId, metricId, [
|
||||
{ node_id: 'node-2', title: 'Retriever Node', type: 'retriever' },
|
||||
])
|
||||
|
||||
const metric = useEvaluationStore.getState().resources['workflow:app-4'].metrics.find(item => item.optionId === metricId)
|
||||
|
||||
expect(metric?.nodeInfoList).toEqual([
|
||||
{ node_id: 'node-2', title: 'Retriever Node', type: 'retriever' },
|
||||
])
|
||||
expect(useEvaluationStore.getState().resources['workflow:app-4'].metrics.filter(item => item.optionId === metricId)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should update condition groups and adapt operators to field types', () => {
|
||||
const resourceType = 'pipeline'
|
||||
const resourceId = 'dataset-1'
|
||||
const store = useEvaluationStore.getState()
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
|
||||
store.ensureResource(resourceType, resourceId)
|
||||
|
||||
const initialGroup = useEvaluationStore.getState().resources['pipeline:dataset-1'].conditions[0]
|
||||
store.setConditionGroupOperator(resourceType, resourceId, initialGroup.id, 'or')
|
||||
store.addConditionGroup(resourceType, resourceId)
|
||||
|
||||
const booleanField = config.fieldOptions.find(field => field.type === 'boolean')!
|
||||
const currentItem = useEvaluationStore.getState().resources['pipeline:dataset-1'].conditions[0].items[0]
|
||||
store.updateConditionField(resourceType, resourceId, initialGroup.id, currentItem.id, booleanField.id)
|
||||
|
||||
const updatedGroup = useEvaluationStore.getState().resources['pipeline:dataset-1'].conditions[0]
|
||||
expect(updatedGroup.logicalOperator).toBe('or')
|
||||
expect(updatedGroup.items[0].operator).toBe('is')
|
||||
expect(getAllowedOperators(resourceType, booleanField.id)).toEqual(['is', 'is_not'])
|
||||
})
|
||||
|
||||
it('should support time fields and clear values for empty operators', () => {
|
||||
const resourceType = 'workflow'
|
||||
const resourceId = 'app-3'
|
||||
const store = useEvaluationStore.getState()
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
|
||||
store.ensureResource(resourceType, resourceId)
|
||||
|
||||
const timeField = config.fieldOptions.find(field => field.type === 'time')!
|
||||
const item = useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].items[0]
|
||||
|
||||
store.updateConditionField(resourceType, resourceId, useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].id, item.id, timeField.id)
|
||||
store.updateConditionOperator(resourceType, resourceId, useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].id, item.id, 'is_empty')
|
||||
|
||||
const updatedItem = useEvaluationStore.getState().resources['workflow:app-3'].conditions[0].items[0]
|
||||
|
||||
expect(getAllowedOperators(resourceType, timeField.id)).toEqual(['is', 'before', 'after', 'is_empty', 'is_not_empty'])
|
||||
expect(requiresConditionValue('is_empty')).toBe(false)
|
||||
expect(updatedItem.value).toBeNull()
|
||||
})
|
||||
})
|
||||
179
web/app/components/evaluation/components/batch-test-panel.tsx
Normal file
179
web/app/components/evaluation/components/batch-test-panel.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
'use client'
|
||||
|
||||
import type { EvaluationResourceProps } from '../types'
|
||||
import { useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { getEvaluationMockConfig } from '../mock'
|
||||
import { isEvaluationRunnable, useEvaluationResource, useEvaluationStore } from '../store'
|
||||
import { TAB_CLASS_NAME } from '../utils'
|
||||
|
||||
const BatchTestPanel = ({
|
||||
resourceType,
|
||||
resourceId,
|
||||
}: EvaluationResourceProps) => {
|
||||
const { t } = useTranslation('evaluation')
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
const requirementFields = config.fieldOptions
|
||||
.filter(field => field.id.includes('.input.') || field.group.toLowerCase().includes('input'))
|
||||
.slice(0, 4)
|
||||
const displayedRequirementFields = requirementFields.length > 0 ? requirementFields : config.fieldOptions.slice(0, 4)
|
||||
const tabLabels = {
|
||||
'input-fields': t('batch.tabs.input-fields'),
|
||||
'history': t('batch.tabs.history'),
|
||||
}
|
||||
const statusLabels = {
|
||||
running: t('batch.status.running'),
|
||||
success: t('batch.status.success'),
|
||||
failed: t('batch.status.failed'),
|
||||
}
|
||||
const resource = useEvaluationResource(resourceType, resourceId)
|
||||
const setBatchTab = useEvaluationStore(state => state.setBatchTab)
|
||||
const setUploadedFileName = useEvaluationStore(state => state.setUploadedFileName)
|
||||
const runBatchTest = useEvaluationStore(state => state.runBatchTest)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const isRunnable = isEvaluationRunnable(resource)
|
||||
const isPanelReady = !!resource.judgeModelId && resource.metrics.length > 0
|
||||
|
||||
const handleDownloadTemplate = () => {
|
||||
const content = ['case_id,input,expected', '1,Example input,Example output'].join('\n')
|
||||
const link = document.createElement('a')
|
||||
link.href = `data:text/csv;charset=utf-8,${encodeURIComponent(content)}`
|
||||
link.download = config.templateFileName
|
||||
link.click()
|
||||
}
|
||||
|
||||
const handleRun = () => {
|
||||
if (!isRunnable) {
|
||||
toast.warning(t('batch.validation'))
|
||||
return
|
||||
}
|
||||
|
||||
runBatchTest(resourceType, resourceId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background-default">
|
||||
<div className="px-6 py-4">
|
||||
<div className="text-text-primary system-xl-semibold">{t('batch.title')}</div>
|
||||
<div className="mt-1 text-text-tertiary system-sm-regular">{t('batch.description')}</div>
|
||||
<div className="mt-4 rounded-xl border border-divider-subtle bg-components-card-bg p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<span aria-hidden="true" className="i-ri-alert-fill mt-0.5 h-4 w-4 shrink-0 text-text-warning" />
|
||||
<div className="text-text-tertiary system-xs-regular">{t('batch.noticeDescription')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b border-divider-subtle px-6">
|
||||
<div className="flex gap-4">
|
||||
{(['input-fields', 'history'] as const).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
className={cn(
|
||||
TAB_CLASS_NAME,
|
||||
'flex-none rounded-none border-b-2 border-transparent px-0 pb-2.5 pt-2 uppercase',
|
||||
resource.activeBatchTab === tab ? 'border-text-accent-secondary text-text-primary' : 'text-text-tertiary',
|
||||
)}
|
||||
onClick={() => setBatchTab(resourceType, resourceId, tab)}
|
||||
>
|
||||
{tabLabels[tab]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn('min-h-0 flex-1 overflow-y-auto px-6 py-4', !isPanelReady && 'opacity-50')}>
|
||||
{resource.activeBatchTab === 'input-fields' && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<div className="text-text-primary system-md-semibold">{t('batch.requirementsTitle')}</div>
|
||||
<div className="mt-1 text-text-tertiary system-xs-regular">{t('batch.requirementsDescription')}</div>
|
||||
<div className="mt-3 rounded-xl bg-background-section p-3">
|
||||
{displayedRequirementFields.map(field => (
|
||||
<div key={field.id} className="flex items-center py-1">
|
||||
<div className="rounded px-1 py-0.5 text-text-tertiary system-xs-medium">
|
||||
{field.label}
|
||||
</div>
|
||||
<div className="text-[10px] leading-3 text-text-quaternary">
|
||||
{field.type}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Button variant="secondary" className="w-full justify-center" disabled={!isPanelReady} onClick={handleDownloadTemplate}>
|
||||
<span aria-hidden="true" className="i-ri-download-line mr-1 h-4 w-4" />
|
||||
{t('batch.downloadTemplate')}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
hidden
|
||||
type="file"
|
||||
accept=".csv,.xlsx"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
setUploadedFileName(resourceType, resourceId, file?.name ?? null)
|
||||
}}
|
||||
/>
|
||||
{isPanelReady && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full flex-col items-center justify-center rounded-xl border border-dashed border-divider-subtle bg-background-default-subtle px-4 py-6 text-center hover:border-components-button-secondary-border"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-file-upload-line h-5 w-5 text-text-tertiary" />
|
||||
<div className="mt-2 text-text-primary system-sm-semibold">{t('batch.uploadTitle')}</div>
|
||||
<div className="mt-1 text-text-tertiary system-xs-regular">{resource.uploadedFileName ?? t('batch.uploadHint')}</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!isRunnable && (
|
||||
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-2 text-text-tertiary system-xs-regular">
|
||||
{t('batch.validation')}
|
||||
</div>
|
||||
)}
|
||||
<Button className="w-full justify-center" variant="primary" disabled={!isRunnable} onClick={handleRun}>
|
||||
{t('batch.run')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{resource.activeBatchTab === 'history' && (
|
||||
<div className="space-y-3">
|
||||
{resource.batchRecords.length === 0 && (
|
||||
<div className="rounded-2xl border border-dashed border-divider-subtle px-4 py-10 text-center text-text-tertiary system-sm-regular">
|
||||
{t('batch.emptyHistory')}
|
||||
</div>
|
||||
)}
|
||||
{resource.batchRecords.map(record => (
|
||||
<div key={record.id} className="rounded-2xl border border-divider-subtle bg-background-default-subtle p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-text-primary system-sm-semibold">{record.summary}</div>
|
||||
<div className="mt-1 text-text-tertiary system-xs-regular">{record.fileName}</div>
|
||||
</div>
|
||||
<Badge className={record.status === 'failed' ? 'badge-warning' : record.status === 'success' ? 'badge-accent' : ''}>
|
||||
{record.status === 'running'
|
||||
? (
|
||||
<span className="flex items-center gap-1">
|
||||
<span aria-hidden="true" className="i-ri-loader-4-line h-3 w-3 animate-spin" />
|
||||
{statusLabels.running}
|
||||
</span>
|
||||
)
|
||||
: statusLabels[record.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-3 text-text-tertiary system-xs-regular">{record.startedAt}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BatchTestPanel
|
||||
344
web/app/components/evaluation/components/condition-group.tsx
Normal file
344
web/app/components/evaluation/components/condition-group.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
ComparisonOperator,
|
||||
EvaluationFieldOption,
|
||||
EvaluationResourceProps,
|
||||
JudgmentConditionGroup,
|
||||
} from '../types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Button from '@/app/components/base/button'
|
||||
import DatePicker from '@/app/components/base/date-and-time-picker/date-picker'
|
||||
import dayjs from '@/app/components/base/date-and-time-picker/utils/dayjs'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectGroupLabel,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/app/components/base/ui/select'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { getEvaluationMockConfig } from '../mock'
|
||||
import { getAllowedOperators, requiresConditionValue, useEvaluationStore } from '../store'
|
||||
import { getFieldTypeIconClassName, getOperatorLabel, groupFieldOptions } from '../utils'
|
||||
|
||||
type ConditionFieldLabelProps = {
|
||||
field?: EvaluationFieldOption
|
||||
placeholder: string
|
||||
}
|
||||
|
||||
type ConditionFieldSelectProps = {
|
||||
field?: EvaluationFieldOption
|
||||
fieldOptions: EvaluationFieldOption[]
|
||||
placeholder: string
|
||||
onChange: (fieldId: string) => void
|
||||
}
|
||||
|
||||
type ConditionOperatorSelectProps = {
|
||||
field?: EvaluationFieldOption
|
||||
operator: ComparisonOperator
|
||||
operators: ComparisonOperator[]
|
||||
onChange: (operator: ComparisonOperator) => void
|
||||
}
|
||||
|
||||
type FieldValueInputProps = {
|
||||
field?: EvaluationFieldOption
|
||||
operator: ComparisonOperator
|
||||
value: string | number | boolean | null
|
||||
onChange: (value: string | number | boolean | null) => void
|
||||
}
|
||||
|
||||
type ConditionGroupProps = EvaluationResourceProps & {
|
||||
group: JudgmentConditionGroup
|
||||
index: number
|
||||
}
|
||||
|
||||
const ConditionFieldLabel = ({
|
||||
field,
|
||||
placeholder,
|
||||
}: ConditionFieldLabelProps) => {
|
||||
if (!field)
|
||||
return <span className="px-1 text-components-input-text-placeholder system-sm-regular">{placeholder}</span>
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 px-1">
|
||||
<div className="inline-flex h-6 min-w-0 items-center gap-1 rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark pl-[5px] pr-1.5 shadow-xs">
|
||||
<span className={cn(getFieldTypeIconClassName(field.type), 'h-3 w-3 shrink-0 text-text-secondary')} />
|
||||
<span className="truncate text-text-secondary system-xs-medium">{field.label}</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-text-tertiary system-xs-regular">{field.type}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ConditionFieldSelect = ({
|
||||
field,
|
||||
fieldOptions,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: ConditionFieldSelectProps) => {
|
||||
return (
|
||||
<Select value={field?.id ?? ''} onValueChange={value => value && onChange(value)}>
|
||||
<SelectTrigger className="h-auto bg-transparent px-1 py-1 hover:bg-transparent focus-visible:bg-transparent">
|
||||
<ConditionFieldLabel field={field} placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName="w-[320px]">
|
||||
{groupFieldOptions(fieldOptions).map(([groupName, fields]) => (
|
||||
<SelectGroup key={groupName}>
|
||||
<SelectGroupLabel className="px-3 pb-1 pt-2 text-text-tertiary system-xs-medium-uppercase">{groupName}</SelectGroupLabel>
|
||||
{fields.map(option => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className={cn(getFieldTypeIconClassName(option.type), 'h-3.5 w-3.5 shrink-0 text-text-tertiary')} />
|
||||
<span className="truncate">{option.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
const ConditionOperatorSelect = ({
|
||||
field,
|
||||
operator,
|
||||
operators,
|
||||
onChange,
|
||||
}: ConditionOperatorSelectProps) => {
|
||||
const { t } = useTranslation('evaluation')
|
||||
|
||||
return (
|
||||
<Select value={operator} onValueChange={value => value && onChange(value as ComparisonOperator)}>
|
||||
<SelectTrigger className="h-8 w-auto min-w-[88px] gap-1 rounded-md bg-transparent px-1.5 py-0 hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt">
|
||||
<span className="truncate text-text-secondary system-xs-medium">{getOperatorLabel(operator, field?.type, t)}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[1002]" popupClassName="w-[240px] bg-components-panel-bg-blur backdrop-blur-[10px]">
|
||||
{operators.map(nextOperator => (
|
||||
<SelectItem key={nextOperator} value={nextOperator}>
|
||||
{getOperatorLabel(nextOperator, field?.type, t)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
const FieldValueInput = ({
|
||||
field,
|
||||
operator,
|
||||
value,
|
||||
onChange,
|
||||
}: FieldValueInputProps) => {
|
||||
const { t } = useTranslation('evaluation')
|
||||
|
||||
if (!field || !requiresConditionValue(operator))
|
||||
return null
|
||||
|
||||
if (field.type === 'time') {
|
||||
const selectedTime = typeof value === 'string' && value ? dayjs(value) : undefined
|
||||
|
||||
return (
|
||||
<div className="px-2 py-1.5">
|
||||
<DatePicker
|
||||
value={selectedTime}
|
||||
onChange={date => onChange(date ? date.toISOString() : null)}
|
||||
onClear={() => onChange(null)}
|
||||
placeholder={t('conditions.selectTime')}
|
||||
triggerWrapClassName="w-full"
|
||||
popupZIndexClassname="z-[1002]"
|
||||
renderTrigger={({ handleClickTrigger }) => (
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full items-center gap-2 rounded-md px-1 py-1 text-left hover:bg-state-base-hover-alt"
|
||||
onClick={handleClickTrigger}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate system-sm-regular',
|
||||
selectedTime ? 'text-text-secondary' : 'text-components-input-text-placeholder',
|
||||
)}
|
||||
>
|
||||
{selectedTime ? selectedTime.format('MMM D, YYYY h:mm A') : t('conditions.selectTime')}
|
||||
</span>
|
||||
<span className="i-ri-calendar-line h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'boolean') {
|
||||
return (
|
||||
<div className="px-2 py-1.5">
|
||||
<Select value={value === null ? '' : String(value)} onValueChange={nextValue => onChange(nextValue === 'true')}>
|
||||
<SelectTrigger className="bg-transparent hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt">
|
||||
<SelectValue placeholder={t('conditions.selectValue')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">{t('conditions.boolean.true')}</SelectItem>
|
||||
<SelectItem value="false">{t('conditions.boolean.false')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'enum') {
|
||||
return (
|
||||
<div className="px-2 py-1.5">
|
||||
<Select value={typeof value === 'string' ? value : ''} onValueChange={nextValue => onChange(nextValue)}>
|
||||
<SelectTrigger className="bg-transparent hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt">
|
||||
<SelectValue placeholder={t('conditions.selectValue')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(field.options ?? []).map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-2 py-1.5">
|
||||
<Input
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
value={value === null || typeof value === 'boolean' ? '' : value}
|
||||
className="border-none bg-transparent shadow-none hover:border-none hover:bg-state-base-hover-alt focus:border-none focus:bg-state-base-hover-alt focus:shadow-none"
|
||||
placeholder={t('conditions.valuePlaceholder')}
|
||||
onChange={(e) => {
|
||||
if (field.type === 'number') {
|
||||
const nextValue = e.target.value
|
||||
onChange(nextValue === '' ? null : Number(nextValue))
|
||||
return
|
||||
}
|
||||
|
||||
onChange(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ConditionGroup = ({
|
||||
resourceType,
|
||||
resourceId,
|
||||
group,
|
||||
index,
|
||||
}: ConditionGroupProps) => {
|
||||
const { t } = useTranslation('evaluation')
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
const logicalLabels = {
|
||||
and: t('conditions.logical.and'),
|
||||
or: t('conditions.logical.or'),
|
||||
}
|
||||
const removeConditionGroup = useEvaluationStore(state => state.removeConditionGroup)
|
||||
const setConditionGroupOperator = useEvaluationStore(state => state.setConditionGroupOperator)
|
||||
const addConditionItem = useEvaluationStore(state => state.addConditionItem)
|
||||
const removeConditionItem = useEvaluationStore(state => state.removeConditionItem)
|
||||
const updateConditionField = useEvaluationStore(state => state.updateConditionField)
|
||||
const updateConditionOperator = useEvaluationStore(state => state.updateConditionOperator)
|
||||
const updateConditionValue = useEvaluationStore(state => state.updateConditionValue)
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-divider-subtle bg-components-card-bg p-4">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge>{t('conditions.groupLabel', { index: index + 1 })}</Badge>
|
||||
<div className="flex rounded-lg border border-divider-subtle bg-background-default-subtle p-1">
|
||||
{(['and', 'or'] as const).map(operator => (
|
||||
<button
|
||||
key={operator}
|
||||
type="button"
|
||||
className={cn(
|
||||
'rounded-md px-3 py-1.5 system-xs-medium-uppercase',
|
||||
group.logicalOperator === operator
|
||||
? 'bg-components-card-bg text-text-primary shadow-xs'
|
||||
: 'text-text-tertiary',
|
||||
)}
|
||||
onClick={() => setConditionGroupOperator(resourceType, resourceId, group.id, operator)}
|
||||
>
|
||||
{logicalLabels[operator]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="small" variant="ghost" onClick={() => addConditionItem(resourceType, resourceId, group.id)}>
|
||||
<span aria-hidden="true" className="i-ri-add-line mr-1 h-4 w-4" />
|
||||
{t('conditions.addCondition')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
aria-label={t('conditions.removeGroup')}
|
||||
onClick={() => removeConditionGroup(resourceType, resourceId, group.id)}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-delete-bin-line h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{group.items.map((item) => {
|
||||
const field = config.fieldOptions.find(option => option.id === item.fieldId)
|
||||
const allowedOperators = getAllowedOperators(resourceType, item.fieldId)
|
||||
const showValue = !!field && requiresConditionValue(item.operator)
|
||||
|
||||
return (
|
||||
<div key={item.id} className="flex items-start overflow-hidden rounded-lg">
|
||||
<div className="min-w-0 flex-1 rounded-lg bg-components-input-bg-normal">
|
||||
<div className="flex items-center gap-0 pr-1">
|
||||
<div className="min-w-0 flex-1 py-1">
|
||||
<ConditionFieldSelect
|
||||
field={field}
|
||||
fieldOptions={config.fieldOptions}
|
||||
placeholder={t('conditions.fieldPlaceholder')}
|
||||
onChange={value => updateConditionField(resourceType, resourceId, group.id, item.id, value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-3 w-px bg-divider-regular" />
|
||||
<ConditionOperatorSelect
|
||||
field={field}
|
||||
operator={item.operator}
|
||||
operators={allowedOperators}
|
||||
onChange={value => updateConditionOperator(resourceType, resourceId, group.id, item.id, value)}
|
||||
/>
|
||||
</div>
|
||||
{showValue && (
|
||||
<div className="border-t border-divider-subtle">
|
||||
<FieldValueInput
|
||||
field={field}
|
||||
operator={item.operator}
|
||||
value={item.value}
|
||||
onChange={value => updateConditionValue(resourceType, resourceId, group.id, item.id, value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="pl-1 pt-1">
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
aria-label={t('conditions.removeCondition')}
|
||||
onClick={() => removeConditionItem(resourceType, resourceId, group.id, item.id)}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-close-line h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConditionGroup
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import type { EvaluationResourceProps } from '../types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useEvaluationResource, useEvaluationStore } from '../store'
|
||||
import ConditionGroup from './condition-group'
|
||||
import { InlineSectionHeader } from './section-header'
|
||||
|
||||
const ConditionsSection = ({
|
||||
resourceType,
|
||||
resourceId,
|
||||
}: EvaluationResourceProps) => {
|
||||
const { t } = useTranslation('evaluation')
|
||||
const resource = useEvaluationResource(resourceType, resourceId)
|
||||
const addConditionGroup = useEvaluationStore(state => state.addConditionGroup)
|
||||
const canAddCondition = resource.metrics.length > 0
|
||||
|
||||
return (
|
||||
<section className="max-w-[700px] py-4">
|
||||
<InlineSectionHeader
|
||||
title={t('conditions.title')}
|
||||
tooltip={t('conditions.description')}
|
||||
/>
|
||||
<div className="mt-2 space-y-4">
|
||||
{resource.conditions.length === 0 && (
|
||||
<div className="rounded-xl bg-background-section px-3 py-3 text-text-tertiary system-xs-regular">
|
||||
{t('conditions.emptyDescription')}
|
||||
</div>
|
||||
)}
|
||||
{resource.conditions.map((group, index) => (
|
||||
<ConditionGroup
|
||||
key={group.id}
|
||||
resourceType={resourceType}
|
||||
resourceId={resourceId}
|
||||
group={group}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex items-center text-text-accent system-sm-medium',
|
||||
!canAddCondition && 'cursor-not-allowed text-components-button-secondary-accent-text-disabled',
|
||||
)}
|
||||
disabled={!canAddCondition}
|
||||
onClick={() => addConditionGroup(resourceType, resourceId)}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-add-line mr-1 h-4 w-4" />
|
||||
{t('conditions.addCondition')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConditionsSection
|
||||
@@ -0,0 +1,151 @@
|
||||
'use client'
|
||||
|
||||
import type { CustomMetricMapping, EvaluationMetric, EvaluationResourceProps, EvaluationResourceType } from '../types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Button from '@/app/components/base/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectGroupLabel,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/app/components/base/ui/select'
|
||||
import { getEvaluationMockConfig } from '../mock'
|
||||
import { isCustomMetricConfigured, useEvaluationStore } from '../store'
|
||||
import { groupFieldOptions } from '../utils'
|
||||
|
||||
type CustomMetricEditorProps = EvaluationResourceProps & {
|
||||
metric: EvaluationMetric
|
||||
}
|
||||
|
||||
type MappingRowProps = {
|
||||
resourceType: EvaluationResourceType
|
||||
mapping: CustomMetricMapping
|
||||
targetOptions: Array<{ id: string, label: string }>
|
||||
onUpdate: (patch: { sourceFieldId?: string | null, targetVariableId?: string | null }) => void
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
function MappingRow({
|
||||
resourceType,
|
||||
mapping,
|
||||
targetOptions,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
}: MappingRowProps) {
|
||||
const { t } = useTranslation('evaluation')
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 rounded-xl border border-divider-subtle bg-components-card-bg p-3 xl:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)_auto]">
|
||||
<Select value={mapping.sourceFieldId ?? ''} onValueChange={value => onUpdate({ sourceFieldId: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('metrics.custom.sourcePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupFieldOptions(config.fieldOptions).map(([groupName, fields]) => (
|
||||
<SelectGroup key={groupName}>
|
||||
<SelectGroupLabel>{groupName}</SelectGroupLabel>
|
||||
{fields.map(field => (
|
||||
<SelectItem key={field.id} value={field.id}>{field.label}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center justify-center text-text-quaternary">
|
||||
<span aria-hidden="true" className="i-ri-arrow-down-s-line h-4 w-4 -rotate-90" />
|
||||
</div>
|
||||
<Select value={mapping.targetVariableId ?? ''} onValueChange={value => onUpdate({ targetVariableId: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('metrics.custom.targetPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetOptions.map(option => (
|
||||
<SelectItem key={option.id} value={option.id}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="ghost" size="small" aria-label={t('metrics.remove')} onClick={onRemove}>
|
||||
<span aria-hidden="true" className="i-ri-delete-bin-line h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const CustomMetricEditor = ({
|
||||
resourceType,
|
||||
resourceId,
|
||||
metric,
|
||||
}: CustomMetricEditorProps) => {
|
||||
const { t } = useTranslation('evaluation')
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
const setCustomMetricWorkflow = useEvaluationStore(state => state.setCustomMetricWorkflow)
|
||||
const addCustomMetricMapping = useEvaluationStore(state => state.addCustomMetricMapping)
|
||||
const updateCustomMetricMapping = useEvaluationStore(state => state.updateCustomMetricMapping)
|
||||
const removeCustomMetricMapping = useEvaluationStore(state => state.removeCustomMetricMapping)
|
||||
const selectedWorkflow = config.workflowOptions.find(option => option.id === metric.customConfig?.workflowId)
|
||||
const isConfigured = isCustomMetricConfigured(metric)
|
||||
|
||||
if (!metric.customConfig)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-xl border border-divider-subtle bg-background-default-subtle p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-text-primary system-sm-semibold">{t('metrics.custom.title')}</div>
|
||||
<div className="mt-1 text-text-tertiary system-xs-regular">{t('metrics.custom.description')}</div>
|
||||
</div>
|
||||
{!isConfigured && <Badge className="badge-warning">{t('metrics.custom.warningBadge')}</Badge>}
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 xl:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<div>
|
||||
<div className="mb-2 text-text-secondary system-xs-medium-uppercase">{t('metrics.custom.workflowLabel')}</div>
|
||||
<Select value={metric.customConfig.workflowId ?? ''} onValueChange={value => value && setCustomMetricWorkflow(resourceType, resourceId, metric.id, value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('metrics.custom.workflowPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{config.workflowOptions.map(option => (
|
||||
<SelectItem key={option.id} value={option.id}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedWorkflow && <div className="mt-2 text-text-tertiary system-xs-regular">{selectedWorkflow.description}</div>}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="text-text-secondary system-xs-medium-uppercase">{t('metrics.custom.mappingTitle')}</div>
|
||||
<Button size="small" variant="ghost" onClick={() => addCustomMetricMapping(resourceType, resourceId, metric.id)}>
|
||||
<span aria-hidden="true" className="i-ri-add-line mr-1 h-4 w-4" />
|
||||
{t('metrics.custom.addMapping')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{metric.customConfig.mappings.map(mapping => (
|
||||
<MappingRow
|
||||
key={mapping.id}
|
||||
resourceType={resourceType}
|
||||
mapping={mapping}
|
||||
targetOptions={selectedWorkflow?.targetVariables ?? []}
|
||||
onUpdate={patch => updateCustomMetricMapping(resourceType, resourceId, metric.id, mapping.id, patch)}
|
||||
onRemove={() => removeCustomMetricMapping(resourceType, resourceId, metric.id, mapping.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!isConfigured && (
|
||||
<div className="mt-3 rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-2 text-text-tertiary system-xs-regular">
|
||||
{t('metrics.custom.mappingWarning')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomMetricEditor
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import type { EvaluationResourceProps } from '../types'
|
||||
import { useEffect } from 'react'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
|
||||
import { useEvaluationResource, useEvaluationStore } from '../store'
|
||||
import { decodeModelSelection, encodeModelSelection } from '../utils'
|
||||
|
||||
const JudgeModelSelector = ({
|
||||
resourceType,
|
||||
resourceId,
|
||||
}: EvaluationResourceProps) => {
|
||||
const { data: modelList } = useModelList(ModelTypeEnum.textGeneration)
|
||||
const resource = useEvaluationResource(resourceType, resourceId)
|
||||
const setJudgeModel = useEvaluationStore(state => state.setJudgeModel)
|
||||
const selectedModel = decodeModelSelection(resource.judgeModelId)
|
||||
|
||||
useEffect(() => {
|
||||
if (resource.judgeModelId || !modelList.length)
|
||||
return
|
||||
|
||||
const firstProvider = modelList[0]
|
||||
const firstModel = firstProvider.models[0]
|
||||
if (!firstProvider || !firstModel)
|
||||
return
|
||||
|
||||
setJudgeModel(resourceType, resourceId, encodeModelSelection(firstProvider.provider, firstModel.model))
|
||||
}, [modelList, resource.judgeModelId, resourceId, resourceType, setJudgeModel])
|
||||
|
||||
return (
|
||||
<ModelSelector
|
||||
defaultModel={selectedModel}
|
||||
modelList={modelList}
|
||||
onSelect={model => setJudgeModel(resourceType, resourceId, encodeModelSelection(model.provider, model.model))}
|
||||
showDeprecatedWarnIcon
|
||||
triggerClassName="h-8 w-full rounded-lg"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default JudgeModelSelector
|
||||
93
web/app/components/evaluation/components/metric-section.tsx
Normal file
93
web/app/components/evaluation/components/metric-section.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client'
|
||||
|
||||
import type { EvaluationResourceProps } from '../types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useEvaluationResource, useEvaluationStore } from '../store'
|
||||
import CustomMetricEditor from './custom-metric-editor'
|
||||
import MetricSelector from './metric-selector'
|
||||
import { InlineSectionHeader } from './section-header'
|
||||
|
||||
const MetricSection = ({
|
||||
resourceType,
|
||||
resourceId,
|
||||
}: EvaluationResourceProps) => {
|
||||
const { t } = useTranslation('evaluation')
|
||||
const resource = useEvaluationResource(resourceType, resourceId)
|
||||
const removeMetric = useEvaluationStore(state => state.removeMetric)
|
||||
const hasMetrics = resource.metrics.length > 0
|
||||
|
||||
return (
|
||||
<section className="max-w-[700px] py-4">
|
||||
<InlineSectionHeader
|
||||
title={t('metrics.title')}
|
||||
tooltip={t('metrics.description')}
|
||||
/>
|
||||
<div className="mt-2 space-y-3">
|
||||
{!hasMetrics && (
|
||||
<div className="flex items-center gap-5 rounded-xl bg-background-section px-3 py-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-md">
|
||||
<span aria-hidden="true" className="i-ri-bar-chart-horizontal-line h-6 w-6 text-text-primary" />
|
||||
</div>
|
||||
<div className="text-text-tertiary system-xs-regular">
|
||||
{t('metrics.description')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{resource.metrics.map(metric => (
|
||||
<div key={metric.id} className="rounded-2xl border border-divider-subtle bg-components-card-bg p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-text-primary system-sm-semibold">{metric.label}</div>
|
||||
<div className="mt-1 text-text-tertiary system-xs-regular">{metric.description}</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{metric.badges.map(badge => (
|
||||
<Badge key={badge} className={badge === 'Workflow' ? 'badge-accent' : ''}>{badge}</Badge>
|
||||
))}
|
||||
</div>
|
||||
{metric.kind === 'builtin' && (
|
||||
<div className="mt-3 rounded-xl bg-background-default-subtle px-3 py-2">
|
||||
<div className="text-text-secondary system-2xs-medium-uppercase">{t('metrics.nodesLabel')}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{metric.nodeInfoList?.length
|
||||
? metric.nodeInfoList.map(nodeInfo => (
|
||||
<Badge key={nodeInfo.node_id} className="badge-accent">
|
||||
{nodeInfo.title}
|
||||
</Badge>
|
||||
))
|
||||
: (
|
||||
<span className="text-text-tertiary system-xs-regular">{t('metrics.nodesAll')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
aria-label={t('metrics.remove')}
|
||||
onClick={() => removeMetric(resourceType, resourceId, metric.id)}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-delete-bin-line h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{metric.kind === 'custom-workflow' && (
|
||||
<CustomMetricEditor
|
||||
resourceType={resourceType}
|
||||
resourceId={resourceId}
|
||||
metric={metric}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<MetricSelector
|
||||
resourceType={resourceType}
|
||||
resourceId={resourceId}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default MetricSection
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client'
|
||||
|
||||
import type { ChangeEvent } from 'react'
|
||||
import type { MetricSelectorProps } from './types'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/app/components/base/ui/popover'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useEvaluationStore } from '../../store'
|
||||
import SelectorEmptyState from './selector-empty-state'
|
||||
import SelectorFooter from './selector-footer'
|
||||
import SelectorMetricSection from './selector-metric-section'
|
||||
import { useMetricSelectorData } from './use-metric-selector-data'
|
||||
|
||||
const MetricSelector = ({
|
||||
resourceType,
|
||||
resourceId,
|
||||
triggerVariant = 'ghost-accent',
|
||||
triggerClassName,
|
||||
triggerStyle = 'button',
|
||||
}: MetricSelectorProps) => {
|
||||
const { t } = useTranslation('evaluation')
|
||||
const addCustomMetric = useEvaluationStore(state => state.addCustomMetric)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [nodeInfoMap, setNodeInfoMap] = useState<Record<string, Array<{ node_id: string, title: string, type: string }>>>({})
|
||||
const [collapsedMetricMap, setCollapsedMetricMap] = useState<Record<string, boolean>>({})
|
||||
const [expandedMetricNodesMap, setExpandedMetricNodesMap] = useState<Record<string, boolean>>({})
|
||||
|
||||
const {
|
||||
builtinMetricMap,
|
||||
filteredSections,
|
||||
isRemoteLoading,
|
||||
toggleNodeSelection,
|
||||
} = useMetricSelectorData({
|
||||
open,
|
||||
query,
|
||||
resourceType,
|
||||
resourceId,
|
||||
nodeInfoMap,
|
||||
setNodeInfoMap,
|
||||
})
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen)
|
||||
|
||||
if (nextOpen) {
|
||||
setQuery('')
|
||||
setCollapsedMetricMap({})
|
||||
setExpandedMetricNodesMap({})
|
||||
}
|
||||
}
|
||||
|
||||
const handleQueryChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(event.target.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
triggerStyle === 'text'
|
||||
? (
|
||||
<button type="button" className={cn('inline-flex items-center text-text-accent system-sm-medium', triggerClassName)}>
|
||||
<span aria-hidden="true" className="i-ri-add-line mr-1 h-4 w-4" />
|
||||
{t('metrics.add')}
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<Button variant={triggerVariant} className={triggerClassName}>
|
||||
<span aria-hidden="true" className="i-ri-add-line mr-1 h-4 w-4" />
|
||||
{t('metrics.add')}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
/>
|
||||
<PopoverContent popupClassName="w-[360px] overflow-hidden rounded-xl border-[0.5px] border-components-panel-border p-0 shadow-[0px_12px_16px_-4px_rgba(9,9,11,0.08),0px_4px_6px_-2px_rgba(9,9,11,0.03)]">
|
||||
<div className="flex min-h-[560px] flex-col bg-components-panel-bg">
|
||||
<div className="border-b border-divider-subtle bg-background-section-burn px-2 py-2">
|
||||
<Input
|
||||
value={query}
|
||||
showLeftIcon
|
||||
placeholder={t('metrics.searchNodeOrMetrics')}
|
||||
onChange={handleQueryChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{isRemoteLoading && (
|
||||
<div className="space-y-3 px-3 py-4" data-testid="evaluation-metric-loading">
|
||||
{['metric-skeleton-1', 'metric-skeleton-2', 'metric-skeleton-3'].map(key => (
|
||||
<div key={key} className="h-20 animate-pulse rounded-xl bg-background-default-subtle" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isRemoteLoading && filteredSections.length === 0 && (
|
||||
<SelectorEmptyState message={t('metrics.noResults')} />
|
||||
)}
|
||||
|
||||
{!isRemoteLoading && filteredSections.map((section, index) => {
|
||||
const { metric } = section
|
||||
const isExpanded = collapsedMetricMap[metric.id] !== true
|
||||
const isShowingAllNodes = expandedMetricNodesMap[metric.id] === true
|
||||
|
||||
return (
|
||||
<SelectorMetricSection
|
||||
key={metric.id}
|
||||
section={section}
|
||||
index={index}
|
||||
addedMetric={builtinMetricMap.get(metric.id)}
|
||||
isExpanded={isExpanded}
|
||||
isShowingAllNodes={isShowingAllNodes}
|
||||
onToggleExpanded={() => setCollapsedMetricMap(current => ({
|
||||
...current,
|
||||
[metric.id]: isExpanded,
|
||||
}))}
|
||||
onToggleNodeSelection={toggleNodeSelection}
|
||||
onToggleShowAllNodes={() => setExpandedMetricNodesMap(current => ({
|
||||
...current,
|
||||
[metric.id]: !isShowingAllNodes,
|
||||
}))}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<SelectorFooter
|
||||
title={t('metrics.custom.footerTitle')}
|
||||
description={t('metrics.custom.footerDescription')}
|
||||
onClick={() => {
|
||||
addCustomMetric(resourceType, resourceId)
|
||||
setOpen(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default MetricSelector
|
||||
@@ -0,0 +1,26 @@
|
||||
type SelectorEmptyStateProps = {
|
||||
message: string
|
||||
}
|
||||
|
||||
const EmptySearchStateIcon = () => {
|
||||
return (
|
||||
<div className="relative h-8 w-8 text-text-quaternary">
|
||||
<span aria-hidden="true" className="i-ri-search-line absolute bottom-0 right-0 h-6 w-6" />
|
||||
<span aria-hidden="true" className="absolute left-0 top-[9px] h-[2px] w-[7px] rounded-full bg-current opacity-80" />
|
||||
<span aria-hidden="true" className="absolute left-0 top-[16px] h-[2px] w-[4px] rounded-full bg-current opacity-80" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SelectorEmptyState = ({
|
||||
message,
|
||||
}: SelectorEmptyStateProps) => {
|
||||
return (
|
||||
<div className="flex h-full min-h-[524px] flex-col items-center justify-center gap-2 px-4 pb-20 text-center">
|
||||
<EmptySearchStateIcon />
|
||||
<div className="text-text-secondary system-sm-regular">{message}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectorEmptyState
|
||||
@@ -0,0 +1,30 @@
|
||||
type SelectorFooterProps = {
|
||||
title: string
|
||||
description: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const SelectorFooter = ({
|
||||
title,
|
||||
description,
|
||||
onClick,
|
||||
}: SelectorFooterProps) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="relative flex items-center gap-3 overflow-hidden border-t border-divider-subtle bg-background-default-subtle px-4 py-5 text-left hover:bg-state-base-hover-alt"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="absolute -left-6 -top-6 h-28 w-28 rounded-full bg-util-colors-indigo-indigo-100 opacity-50 blur-2xl" />
|
||||
<div className="relative flex h-8 w-8 shrink-0 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-[0px_3px_10px_-2px_rgba(9,9,11,0.08),0px_2px_4px_-2px_rgba(9,9,11,0.06)]">
|
||||
<span aria-hidden="true" className="i-ri-add-line h-[18px] w-[18px] text-text-tertiary" />
|
||||
</div>
|
||||
<div className="relative min-w-0">
|
||||
<div className="text-text-secondary system-sm-semibold">{title}</div>
|
||||
<div className="mt-0.5 text-text-tertiary system-xs-regular">{description}</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectorFooter
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { EvaluationMetric } from '../../types'
|
||||
import type { MetricSelectorSection } from './types'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { getMetricVisual, getNodeVisual, getToneClasses } from './utils'
|
||||
|
||||
type SelectorMetricSectionProps = {
|
||||
section: MetricSelectorSection
|
||||
index: number
|
||||
addedMetric?: EvaluationMetric
|
||||
isExpanded: boolean
|
||||
isShowingAllNodes: boolean
|
||||
onToggleExpanded: () => void
|
||||
onToggleShowAllNodes: () => void
|
||||
onToggleNodeSelection: (metricId: string, nodeInfo: MetricSelectorSection['visibleNodes'][number]) => void
|
||||
t: TFunction<'evaluation'>
|
||||
}
|
||||
|
||||
const SelectorMetricSection = ({
|
||||
section,
|
||||
index,
|
||||
addedMetric,
|
||||
isExpanded,
|
||||
isShowingAllNodes,
|
||||
onToggleExpanded,
|
||||
onToggleShowAllNodes,
|
||||
onToggleNodeSelection,
|
||||
t,
|
||||
}: SelectorMetricSectionProps) => {
|
||||
const { metric, visibleNodes, hasNoNodeInfo } = section
|
||||
const selectedNodeIds = new Set(
|
||||
addedMetric?.nodeInfoList?.length
|
||||
? addedMetric.nodeInfoList.map(nodeInfo => nodeInfo.node_id)
|
||||
: [],
|
||||
)
|
||||
const metricVisual = getMetricVisual(metric.id)
|
||||
const toneClasses = getToneClasses(metricVisual.tone)
|
||||
const hasMoreNodes = visibleNodes.length > 3
|
||||
const shownNodes = hasMoreNodes && !isShowingAllNodes ? visibleNodes.slice(0, 3) : visibleNodes
|
||||
|
||||
return (
|
||||
<div data-testid={`evaluation-metric-option-${metric.id}`}>
|
||||
{index > 0 && (
|
||||
<div className="px-3 pt-1">
|
||||
<div className="h-px w-full bg-divider-subtle" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between px-4 pb-1 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 items-center gap-2"
|
||||
onClick={onToggleExpanded}
|
||||
>
|
||||
<div className={cn('flex h-[18px] w-[18px] items-center justify-center rounded-md', toneClasses.soft)}>
|
||||
<span aria-hidden="true" className={cn(metricVisual.icon, 'h-3.5 w-3.5')} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="truncate text-text-secondary system-xs-medium-uppercase">{metric.label}</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn('i-ri-arrow-down-s-line h-4 w-4 text-text-quaternary transition-transform', !isExpanded && '-rotate-90')}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button type="button" className="p-px text-text-quaternary">
|
||||
<span aria-hidden="true" className="i-ri-question-line h-[14px] w-[14px]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-1 py-1">
|
||||
{hasNoNodeInfo && (
|
||||
<div className="px-3 pb-2 pt-0.5 text-text-tertiary system-sm-regular">
|
||||
{t('metrics.noNodesInWorkflow')}
|
||||
</div>
|
||||
)}
|
||||
{shownNodes.map((nodeInfo) => {
|
||||
const nodeVisual = getNodeVisual(nodeInfo)
|
||||
const nodeToneClasses = getToneClasses(nodeVisual.tone)
|
||||
const isAdded = addedMetric
|
||||
? addedMetric.nodeInfoList?.length
|
||||
? selectedNodeIds.has(nodeInfo.node_id)
|
||||
: true
|
||||
: false
|
||||
|
||||
return (
|
||||
<button
|
||||
key={nodeInfo.node_id}
|
||||
data-testid={`evaluation-metric-node-${metric.id}-${nodeInfo.node_id}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full items-center gap-1 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-state-base-hover-alt',
|
||||
isAdded && 'opacity-50',
|
||||
)}
|
||||
onClick={() => onToggleNodeSelection(metric.id, nodeInfo)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 pr-1">
|
||||
<div className={cn('flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-md border-[0.45px] border-divider-subtle shadow-xs shadow-shadow-shadow-3', nodeToneClasses.solid)}>
|
||||
<span aria-hidden="true" className={cn(nodeVisual.icon, 'h-3.5 w-3.5')} />
|
||||
</div>
|
||||
<span className="truncate text-[13px] font-medium leading-4 text-text-secondary">
|
||||
{nodeInfo.title}
|
||||
</span>
|
||||
</div>
|
||||
{isAdded && (
|
||||
<span className="shrink-0 px-1 text-text-quaternary system-xs-regular">{t('metrics.added')}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{hasMoreNodes && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1 rounded-md px-2 py-1.5 text-left hover:bg-state-base-hover-alt"
|
||||
onClick={onToggleShowAllNodes}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5 pr-1">
|
||||
<div className="flex items-center px-1 text-text-tertiary">
|
||||
<span aria-hidden="true" className={cn(isShowingAllNodes ? 'i-ri-subtract-line' : 'i-ri-more-line', 'h-4 w-4')} />
|
||||
</div>
|
||||
<span className="truncate text-text-tertiary system-xs-regular">
|
||||
{isShowingAllNodes ? t('metrics.showLess') : t('metrics.showMore')}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectorMetricSection
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { EvaluationMetric, EvaluationResourceProps, MetricOption } from '../../types'
|
||||
import type { NodeInfo } from '@/types/evaluation'
|
||||
|
||||
export type MetricSelectorProps = EvaluationResourceProps & {
|
||||
triggerVariant?: 'primary' | 'warning' | 'secondary' | 'secondary-accent' | 'ghost' | 'ghost-accent' | 'tertiary'
|
||||
triggerClassName?: string
|
||||
triggerStyle?: 'button' | 'text'
|
||||
}
|
||||
|
||||
export type MetricVisualTone = 'indigo' | 'green'
|
||||
|
||||
export type MetricSelectorSection = {
|
||||
metric: MetricOption
|
||||
hasNoNodeInfo: boolean
|
||||
visibleNodes: NodeInfo[]
|
||||
}
|
||||
|
||||
export type BuiltinMetricMap = Map<string, EvaluationMetric>
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { BuiltinMetricMap, MetricSelectorSection } from './types'
|
||||
import type { NodeInfo } from '@/types/evaluation'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useAvailableEvaluationMetrics, useEvaluationNodeInfoMutation } from '@/service/use-evaluation'
|
||||
import { getEvaluationMockConfig } from '../../mock'
|
||||
import { useEvaluationResource, useEvaluationStore } from '../../store'
|
||||
import {
|
||||
buildMetricOption,
|
||||
dedupeNodeInfoList,
|
||||
toEvaluationTargetType,
|
||||
} from './utils'
|
||||
|
||||
type UseMetricSelectorDataOptions = {
|
||||
open: boolean
|
||||
query: string
|
||||
resourceType: 'workflow' | 'pipeline' | 'snippet'
|
||||
resourceId: string
|
||||
nodeInfoMap: Record<string, NodeInfo[]>
|
||||
setNodeInfoMap: (value: Record<string, NodeInfo[]>) => void
|
||||
}
|
||||
|
||||
type UseMetricSelectorDataResult = {
|
||||
builtinMetricMap: BuiltinMetricMap
|
||||
filteredSections: MetricSelectorSection[]
|
||||
isRemoteLoading: boolean
|
||||
toggleNodeSelection: (metricId: string, nodeInfo: NodeInfo) => void
|
||||
}
|
||||
|
||||
export const useMetricSelectorData = ({
|
||||
open,
|
||||
query,
|
||||
resourceType,
|
||||
resourceId,
|
||||
nodeInfoMap,
|
||||
setNodeInfoMap,
|
||||
}: UseMetricSelectorDataOptions): UseMetricSelectorDataResult => {
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
const metrics = useEvaluationResource(resourceType, resourceId).metrics
|
||||
const addBuiltinMetric = useEvaluationStore(state => state.addBuiltinMetric)
|
||||
const removeMetric = useEvaluationStore(state => state.removeMetric)
|
||||
const { data: availableMetricsData, isLoading: isAvailableMetricsLoading } = useAvailableEvaluationMetrics(open)
|
||||
const { mutate: loadNodeInfo, isPending: isNodeInfoLoading } = useEvaluationNodeInfoMutation()
|
||||
|
||||
const builtinMetrics = useMemo(() => {
|
||||
return metrics.filter(metric => metric.kind === 'builtin')
|
||||
}, [metrics])
|
||||
|
||||
const builtinMetricMap = useMemo(() => {
|
||||
return new Map(builtinMetrics.map(metric => [metric.optionId, metric] as const))
|
||||
}, [builtinMetrics])
|
||||
|
||||
const availableMetricIds = useMemo(() => availableMetricsData?.metrics ?? [], [availableMetricsData?.metrics])
|
||||
const availableMetricIdsKey = availableMetricIds.join(',')
|
||||
|
||||
const resolvedMetrics = useMemo(() => {
|
||||
const metricsMap = new Map(config.builtinMetrics.map(metric => [metric.id, metric] as const))
|
||||
const defaultGroup = config.builtinMetrics[0]?.group ?? 'other'
|
||||
|
||||
return availableMetricIds.map(metricId => metricsMap.get(metricId) ?? buildMetricOption(metricId, defaultGroup))
|
||||
}, [availableMetricIds, config.builtinMetrics])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open)
|
||||
return
|
||||
|
||||
if (resourceType === 'pipeline' || !resourceId || availableMetricIds.length === 0)
|
||||
return
|
||||
|
||||
let isActive = true
|
||||
|
||||
loadNodeInfo(
|
||||
{
|
||||
params: {
|
||||
targetType: toEvaluationTargetType(resourceType),
|
||||
targetId: resourceId,
|
||||
},
|
||||
body: {
|
||||
metrics: availableMetricIds,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
if (!isActive)
|
||||
return
|
||||
|
||||
setNodeInfoMap(data)
|
||||
},
|
||||
onError: () => {
|
||||
if (!isActive)
|
||||
return
|
||||
|
||||
setNodeInfoMap({})
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return () => {
|
||||
isActive = false
|
||||
}
|
||||
}, [availableMetricIds, availableMetricIdsKey, loadNodeInfo, open, resourceId, resourceType, setNodeInfoMap])
|
||||
|
||||
const filteredSections = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase()
|
||||
|
||||
return resolvedMetrics.map((metric) => {
|
||||
const metricMatches = !keyword
|
||||
|| metric.label.toLowerCase().includes(keyword)
|
||||
|| metric.description.toLowerCase().includes(keyword)
|
||||
const metricNodes = nodeInfoMap[metric.id] ?? []
|
||||
const supportsNodeSelection = resourceType !== 'pipeline'
|
||||
const hasNoNodeInfo = supportsNodeSelection && metricNodes.length === 0
|
||||
|
||||
if (hasNoNodeInfo) {
|
||||
if (!metricMatches)
|
||||
return null
|
||||
|
||||
return {
|
||||
metric,
|
||||
hasNoNodeInfo: true,
|
||||
visibleNodes: [] as NodeInfo[],
|
||||
}
|
||||
}
|
||||
|
||||
const visibleNodes = metricMatches
|
||||
? metricNodes
|
||||
: metricNodes.filter((nodeInfo) => {
|
||||
return nodeInfo.title.toLowerCase().includes(keyword)
|
||||
|| nodeInfo.type.toLowerCase().includes(keyword)
|
||||
|| nodeInfo.node_id.toLowerCase().includes(keyword)
|
||||
})
|
||||
|
||||
if (!metricMatches && visibleNodes.length === 0)
|
||||
return null
|
||||
|
||||
return {
|
||||
metric,
|
||||
hasNoNodeInfo: false,
|
||||
visibleNodes,
|
||||
}
|
||||
}).filter(section => !!section)
|
||||
}, [nodeInfoMap, query, resolvedMetrics, resourceType])
|
||||
|
||||
const toggleNodeSelection = (metricId: string, nodeInfo: NodeInfo) => {
|
||||
const addedMetric = builtinMetricMap.get(metricId)
|
||||
const currentSelectedNodes = addedMetric?.nodeInfoList ?? []
|
||||
|
||||
const nextSelectedNodes = addedMetric && currentSelectedNodes.length === 0
|
||||
? [nodeInfo]
|
||||
: currentSelectedNodes.some(item => item.node_id === nodeInfo.node_id)
|
||||
? currentSelectedNodes.filter(item => item.node_id !== nodeInfo.node_id)
|
||||
: dedupeNodeInfoList([...currentSelectedNodes, nodeInfo])
|
||||
|
||||
if (addedMetric && nextSelectedNodes.length === 0) {
|
||||
removeMetric(resourceType, resourceId, addedMetric.id)
|
||||
return
|
||||
}
|
||||
|
||||
addBuiltinMetric(resourceType, resourceId, metricId, nextSelectedNodes)
|
||||
}
|
||||
|
||||
return {
|
||||
builtinMetricMap,
|
||||
filteredSections,
|
||||
isRemoteLoading: isAvailableMetricsLoading || isNodeInfoLoading,
|
||||
toggleNodeSelection,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { MetricOption } from '../../types'
|
||||
import type { MetricVisualTone } from './types'
|
||||
import type { EvaluationTargetType, NodeInfo } from '@/types/evaluation'
|
||||
|
||||
export const toEvaluationTargetType = (resourceType: 'workflow' | 'snippet'): EvaluationTargetType => {
|
||||
return resourceType === 'snippet' ? 'snippets' : 'app'
|
||||
}
|
||||
|
||||
export const humanizeMetricId = (metricId: string) => {
|
||||
return metricId
|
||||
.split(/[-_]/g)
|
||||
.filter(Boolean)
|
||||
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
export const buildMetricOption = (metricId: string, fallbackGroup: string): MetricOption => ({
|
||||
id: metricId,
|
||||
label: humanizeMetricId(metricId),
|
||||
description: '',
|
||||
group: fallbackGroup,
|
||||
badges: ['Built-in'],
|
||||
})
|
||||
|
||||
export const getMetricVisual = (metricId: string): { icon: string, tone: MetricVisualTone } => {
|
||||
if (['context-precision', 'context-recall'].includes(metricId)) {
|
||||
return {
|
||||
icon: metricId === 'context-recall' ? 'i-ri-arrow-go-back-line' : 'i-ri-focus-2-line',
|
||||
tone: 'green',
|
||||
}
|
||||
}
|
||||
|
||||
if (metricId === 'faithfulness')
|
||||
return { icon: 'i-ri-anchor-line', tone: 'indigo' }
|
||||
|
||||
if (metricId === 'tool-correctness')
|
||||
return { icon: 'i-ri-tools-line', tone: 'indigo' }
|
||||
|
||||
if (metricId === 'task-completion')
|
||||
return { icon: 'i-ri-task-line', tone: 'indigo' }
|
||||
|
||||
if (metricId === 'argument-correctness')
|
||||
return { icon: 'i-ri-scales-3-line', tone: 'indigo' }
|
||||
|
||||
return { icon: 'i-ri-checkbox-circle-line', tone: 'indigo' }
|
||||
}
|
||||
|
||||
export const getNodeVisual = (nodeInfo: NodeInfo): { icon: string, tone: MetricVisualTone } => {
|
||||
const normalizedType = nodeInfo.type.toLowerCase()
|
||||
const normalizedTitle = nodeInfo.title.toLowerCase()
|
||||
|
||||
if (normalizedType.includes('retriev') || normalizedTitle.includes('retriev') || normalizedTitle.includes('knowledge'))
|
||||
return { icon: 'i-ri-book-open-line', tone: 'green' }
|
||||
|
||||
if (normalizedType.includes('agent') || normalizedTitle.includes('agent'))
|
||||
return { icon: 'i-ri-user-star-line', tone: 'indigo' }
|
||||
|
||||
return { icon: 'i-ri-ai-generate-2', tone: 'indigo' }
|
||||
}
|
||||
|
||||
export const getToneClasses = (tone: MetricVisualTone) => {
|
||||
if (tone === 'green') {
|
||||
return {
|
||||
soft: 'bg-util-colors-green-green-50 text-util-colors-green-green-500',
|
||||
solid: 'bg-util-colors-green-green-500 text-white',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
soft: 'bg-util-colors-indigo-indigo-50 text-util-colors-indigo-indigo-500',
|
||||
solid: 'bg-util-colors-indigo-indigo-500 text-white',
|
||||
}
|
||||
}
|
||||
|
||||
export const dedupeNodeInfoList = (nodeInfoList: NodeInfo[]) => {
|
||||
return Array.from(new Map(nodeInfoList.map(nodeInfo => [nodeInfo.node_id, nodeInfo])).values())
|
||||
}
|
||||
76
web/app/components/evaluation/components/section-header.tsx
Normal file
76
web/app/components/evaluation/components/section-header.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type SectionHeaderProps = {
|
||||
title: string
|
||||
description?: ReactNode
|
||||
action?: ReactNode
|
||||
className?: string
|
||||
titleClassName?: string
|
||||
descriptionClassName?: string
|
||||
}
|
||||
|
||||
type InlineSectionHeaderProps = {
|
||||
title: string
|
||||
tooltip?: ReactNode
|
||||
action?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const SectionHeader = ({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
titleClassName,
|
||||
descriptionClassName,
|
||||
}: SectionHeaderProps) => {
|
||||
return (
|
||||
<div className={cn('flex flex-wrap items-start justify-between gap-3', className)}>
|
||||
<div>
|
||||
<div className={cn('text-text-primary system-xl-semibold', titleClassName)}>{title}</div>
|
||||
{description && <div className={cn('mt-1 text-text-tertiary system-sm-regular', descriptionClassName)}>{description}</div>}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const InlineSectionHeader = ({
|
||||
title,
|
||||
tooltip,
|
||||
action,
|
||||
className,
|
||||
}: InlineSectionHeaderProps) => {
|
||||
return (
|
||||
<div className={cn('flex flex-wrap items-center justify-between gap-3', className)}>
|
||||
<div className="flex min-h-6 items-center gap-1">
|
||||
<div className="text-text-primary system-md-semibold">{title}</div>
|
||||
{tooltip && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-4 w-4 items-center justify-center text-text-quaternary transition-colors hover:text-text-tertiary"
|
||||
aria-label={title}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-question-line h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SectionHeader
|
||||
69
web/app/components/evaluation/index.tsx
Normal file
69
web/app/components/evaluation/index.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
import type { EvaluationResourceProps } from './types'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import BatchTestPanel from './components/batch-test-panel'
|
||||
import ConditionsSection from './components/conditions-section'
|
||||
import JudgeModelSelector from './components/judge-model-selector'
|
||||
import MetricSection from './components/metric-section'
|
||||
import SectionHeader, { InlineSectionHeader } from './components/section-header'
|
||||
import { useEvaluationStore } from './store'
|
||||
|
||||
const Evaluation = ({
|
||||
resourceType,
|
||||
resourceId,
|
||||
}: EvaluationResourceProps) => {
|
||||
const { t } = useTranslation('evaluation')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const docLink = useDocLink()
|
||||
const ensureResource = useEvaluationStore(state => state.ensureResource)
|
||||
|
||||
useEffect(() => {
|
||||
ensureResource(resourceType, resourceId)
|
||||
}, [ensureResource, resourceId, resourceType])
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background-default xl:flex-row">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="flex min-h-full max-w-[748px] flex-col px-6 py-4">
|
||||
<SectionHeader
|
||||
title={t('title')}
|
||||
description={(
|
||||
<>
|
||||
{t('description')}
|
||||
{' '}
|
||||
<a
|
||||
className="text-text-accent"
|
||||
href={docLink()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{tCommon('operation.learnMore')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
descriptionClassName="max-w-[700px]"
|
||||
/>
|
||||
<section className="max-w-[700px] py-4">
|
||||
<InlineSectionHeader title={t('judgeModel.title')} tooltip={t('judgeModel.description')} />
|
||||
<div className="mt-1.5">
|
||||
<JudgeModelSelector resourceType={resourceType} resourceId={resourceId} />
|
||||
</div>
|
||||
</section>
|
||||
<div className="max-w-[700px] border-b border-divider-subtle" />
|
||||
<MetricSection resourceType={resourceType} resourceId={resourceId} />
|
||||
<div className="max-w-[700px] border-b border-divider-subtle" />
|
||||
<ConditionsSection resourceType={resourceType} resourceId={resourceId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-[420px] shrink-0 border-t border-divider-subtle xl:h-auto xl:w-[450px] xl:border-l xl:border-t-0">
|
||||
<BatchTestPanel resourceType={resourceType} resourceId={resourceId} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Evaluation
|
||||
184
web/app/components/evaluation/mock.ts
Normal file
184
web/app/components/evaluation/mock.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import type {
|
||||
ComparisonOperator,
|
||||
EvaluationFieldOption,
|
||||
EvaluationMockConfig,
|
||||
EvaluationResourceType,
|
||||
MetricOption,
|
||||
} from './types'
|
||||
|
||||
const judgeModels = [
|
||||
{
|
||||
id: 'gpt-4.1-mini',
|
||||
label: 'GPT-4.1 mini',
|
||||
provider: 'OpenAI',
|
||||
},
|
||||
{
|
||||
id: 'claude-3-7-sonnet',
|
||||
label: 'Claude 3.7 Sonnet',
|
||||
provider: 'Anthropic',
|
||||
},
|
||||
{
|
||||
id: 'gemini-2.0-flash',
|
||||
label: 'Gemini 2.0 Flash',
|
||||
provider: 'Google',
|
||||
},
|
||||
]
|
||||
|
||||
const builtinMetrics: MetricOption[] = [
|
||||
{
|
||||
id: 'answer-correctness',
|
||||
label: 'Answer Correctness',
|
||||
description: 'Compares the response with the expected answer and scores factual alignment.',
|
||||
group: 'quality',
|
||||
badges: ['LLM', 'Built-in'],
|
||||
},
|
||||
{
|
||||
id: 'faithfulness',
|
||||
label: 'Faithfulness',
|
||||
description: 'Checks whether the answer stays grounded in the retrieved evidence.',
|
||||
group: 'quality',
|
||||
badges: ['LLM', 'Retrieval'],
|
||||
},
|
||||
{
|
||||
id: 'relevance',
|
||||
label: 'Relevance',
|
||||
description: 'Evaluates how directly the answer addresses the original request.',
|
||||
group: 'quality',
|
||||
badges: ['LLM'],
|
||||
},
|
||||
{
|
||||
id: 'latency',
|
||||
label: 'Latency',
|
||||
description: 'Captures runtime responsiveness for the full execution path.',
|
||||
group: 'operations',
|
||||
badges: ['System'],
|
||||
},
|
||||
{
|
||||
id: 'token-usage',
|
||||
label: 'Token Usage',
|
||||
description: 'Tracks prompt and completion token consumption for the run.',
|
||||
group: 'operations',
|
||||
badges: ['System'],
|
||||
},
|
||||
{
|
||||
id: 'tool-success-rate',
|
||||
label: 'Tool Success Rate',
|
||||
description: 'Measures whether each required tool invocation finishes without failure.',
|
||||
group: 'operations',
|
||||
badges: ['Workflow'],
|
||||
},
|
||||
]
|
||||
|
||||
const workflowOptions = [
|
||||
{
|
||||
id: 'workflow-precision-review',
|
||||
label: 'Precision Review Workflow',
|
||||
description: 'Custom evaluator for nuanced quality review.',
|
||||
targetVariables: [
|
||||
{ id: 'query', label: 'query' },
|
||||
{ id: 'answer', label: 'answer' },
|
||||
{ id: 'reference', label: 'reference' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'workflow-risk-review',
|
||||
label: 'Risk Review Workflow',
|
||||
description: 'Custom evaluator for policy and escalation checks.',
|
||||
targetVariables: [
|
||||
{ id: 'input', label: 'input' },
|
||||
{ id: 'output', label: 'output' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const workflowFields: EvaluationFieldOption[] = [
|
||||
{ id: 'app.input.query', label: 'Query', group: 'App Input', type: 'string' },
|
||||
{ id: 'app.input.locale', label: 'Locale', group: 'App Input', type: 'enum', options: [{ value: 'en-US', label: 'en-US' }, { value: 'zh-Hans', label: 'zh-Hans' }] },
|
||||
{ id: 'app.output.answer', label: 'Answer', group: 'App Output', type: 'string' },
|
||||
{ id: 'app.output.score', label: 'Score', group: 'App Output', type: 'number' },
|
||||
{ id: 'app.output.published_at', label: 'Publication Date', group: 'App Output', type: 'time' },
|
||||
{ id: 'system.has_context', label: 'Has Context', group: 'System', type: 'boolean' },
|
||||
]
|
||||
|
||||
const pipelineFields: EvaluationFieldOption[] = [
|
||||
{ id: 'dataset.input.document_id', label: 'Document ID', group: 'Dataset', type: 'string' },
|
||||
{ id: 'dataset.input.chunk_count', label: 'Chunk Count', group: 'Dataset', type: 'number' },
|
||||
{ id: 'dataset.input.updated_at', label: 'Updated At', group: 'Dataset', type: 'time' },
|
||||
{ id: 'retrieval.output.hit_rate', label: 'Hit Rate', group: 'Retrieval', type: 'number' },
|
||||
{ id: 'retrieval.output.source', label: 'Source', group: 'Retrieval', type: 'enum', options: [{ value: 'bm25', label: 'BM25' }, { value: 'hybrid', label: 'Hybrid' }] },
|
||||
{ id: 'pipeline.output.published', label: 'Published', group: 'Output', type: 'boolean' },
|
||||
]
|
||||
|
||||
const snippetFields: EvaluationFieldOption[] = [
|
||||
{ id: 'snippet.input.blog_url', label: 'Blog URL', group: 'Snippet Input', type: 'string' },
|
||||
{ id: 'snippet.input.platforms', label: 'Platforms', group: 'Snippet Input', type: 'string' },
|
||||
{ id: 'snippet.output.content', label: 'Generated Content', group: 'Snippet Output', type: 'string' },
|
||||
{ id: 'snippet.output.length', label: 'Output Length', group: 'Snippet Output', type: 'number' },
|
||||
{ id: 'snippet.output.scheduled_at', label: 'Scheduled At', group: 'Snippet Output', type: 'time' },
|
||||
{ id: 'system.requires_review', label: 'Requires Review', group: 'System', type: 'boolean' },
|
||||
]
|
||||
|
||||
export const getComparisonOperators = (fieldType: EvaluationFieldOption['type']): ComparisonOperator[] => {
|
||||
if (fieldType === 'number')
|
||||
return ['is', 'is_not', 'greater_than', 'less_than', 'greater_or_equal', 'less_or_equal', 'is_empty', 'is_not_empty']
|
||||
|
||||
if (fieldType === 'time')
|
||||
return ['is', 'before', 'after', 'is_empty', 'is_not_empty']
|
||||
|
||||
if (fieldType === 'boolean' || fieldType === 'enum')
|
||||
return ['is', 'is_not']
|
||||
|
||||
return ['contains', 'not_contains', 'is', 'is_not', 'is_empty', 'is_not_empty']
|
||||
}
|
||||
|
||||
export const getDefaultOperator = (fieldType: EvaluationFieldOption['type']): ComparisonOperator => {
|
||||
return getComparisonOperators(fieldType)[0]
|
||||
}
|
||||
|
||||
export const getEvaluationMockConfig = (resourceType: EvaluationResourceType): EvaluationMockConfig => {
|
||||
if (resourceType === 'pipeline') {
|
||||
return {
|
||||
judgeModels,
|
||||
builtinMetrics,
|
||||
workflowOptions,
|
||||
fieldOptions: pipelineFields,
|
||||
templateFileName: 'pipeline-evaluation-template.csv',
|
||||
batchRequirements: [
|
||||
'Include one row per retrieval scenario.',
|
||||
'Provide the expected source or target chunk for each case.',
|
||||
'Keep numeric metrics in plain number format.',
|
||||
],
|
||||
historySummaryLabel: 'Pipeline evaluation batch',
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceType === 'snippet') {
|
||||
return {
|
||||
judgeModels,
|
||||
builtinMetrics,
|
||||
workflowOptions,
|
||||
fieldOptions: snippetFields,
|
||||
templateFileName: 'snippet-evaluation-template.csv',
|
||||
batchRequirements: [
|
||||
'Include one row per snippet execution case.',
|
||||
'Provide the expected final content or acceptance rule.',
|
||||
'Keep optional fields empty when not used.',
|
||||
],
|
||||
historySummaryLabel: 'Snippet evaluation batch',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
judgeModels,
|
||||
builtinMetrics,
|
||||
workflowOptions,
|
||||
fieldOptions: workflowFields,
|
||||
templateFileName: 'workflow-evaluation-template.csv',
|
||||
batchRequirements: [
|
||||
'Include one row per workflow test case.',
|
||||
'Provide both user input and expected answer when available.',
|
||||
'Keep boolean columns as true or false.',
|
||||
],
|
||||
historySummaryLabel: 'Workflow evaluation batch',
|
||||
}
|
||||
}
|
||||
182
web/app/components/evaluation/store-utils.ts
Normal file
182
web/app/components/evaluation/store-utils.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type {
|
||||
BatchTestRecord,
|
||||
ComparisonOperator,
|
||||
CustomMetricMapping,
|
||||
EvaluationFieldOption,
|
||||
EvaluationMetric,
|
||||
EvaluationResourceState,
|
||||
EvaluationResourceType,
|
||||
JudgmentConditionGroup,
|
||||
MetricOption,
|
||||
} from './types'
|
||||
import type { NodeInfo } from '@/types/evaluation'
|
||||
import { getComparisonOperators, getDefaultOperator, getEvaluationMockConfig } from './mock'
|
||||
|
||||
export type EvaluationStoreResources = Record<string, EvaluationResourceState>
|
||||
|
||||
const createId = (prefix: string) => `${prefix}-${Math.random().toString(36).slice(2, 10)}`
|
||||
|
||||
export const buildResourceKey = (resourceType: EvaluationResourceType, resourceId: string) => `${resourceType}:${resourceId}`
|
||||
|
||||
export const conditionOperatorsWithoutValue: ComparisonOperator[] = ['is_empty', 'is_not_empty']
|
||||
|
||||
export const requiresConditionValue = (operator: ComparisonOperator) => !conditionOperatorsWithoutValue.includes(operator)
|
||||
|
||||
export const getConditionValue = (
|
||||
field: EvaluationFieldOption | undefined,
|
||||
operator: ComparisonOperator,
|
||||
previousValue: string | number | boolean | null = null,
|
||||
) => {
|
||||
if (!field || !requiresConditionValue(operator))
|
||||
return null
|
||||
|
||||
if (field.type === 'boolean')
|
||||
return typeof previousValue === 'boolean' ? previousValue : null
|
||||
|
||||
if (field.type === 'enum')
|
||||
return typeof previousValue === 'string' ? previousValue : null
|
||||
|
||||
if (field.type === 'number')
|
||||
return typeof previousValue === 'number' ? previousValue : null
|
||||
|
||||
return typeof previousValue === 'string' ? previousValue : null
|
||||
}
|
||||
|
||||
export const createBuiltinMetric = (metric: MetricOption, nodeInfoList: NodeInfo[] = []): EvaluationMetric => ({
|
||||
id: createId('metric'),
|
||||
optionId: metric.id,
|
||||
kind: 'builtin',
|
||||
label: metric.label,
|
||||
description: metric.description,
|
||||
badges: metric.badges,
|
||||
nodeInfoList,
|
||||
})
|
||||
|
||||
export const createCustomMetricMapping = (): CustomMetricMapping => ({
|
||||
id: createId('mapping'),
|
||||
sourceFieldId: null,
|
||||
targetVariableId: null,
|
||||
})
|
||||
|
||||
export const createCustomMetric = (): EvaluationMetric => ({
|
||||
id: createId('metric'),
|
||||
optionId: createId('custom'),
|
||||
kind: 'custom-workflow',
|
||||
label: 'Custom Evaluator',
|
||||
description: 'Map workflow variables to your evaluation inputs.',
|
||||
badges: ['Workflow'],
|
||||
customConfig: {
|
||||
workflowId: null,
|
||||
mappings: [createCustomMetricMapping()],
|
||||
},
|
||||
})
|
||||
|
||||
export const buildConditionItem = (resourceType: EvaluationResourceType) => {
|
||||
const field = getEvaluationMockConfig(resourceType).fieldOptions[0]
|
||||
const operator = field ? getDefaultOperator(field.type) : 'contains'
|
||||
|
||||
return {
|
||||
id: createId('condition'),
|
||||
fieldId: field?.id ?? null,
|
||||
operator,
|
||||
value: getConditionValue(field, operator),
|
||||
}
|
||||
}
|
||||
|
||||
export const createConditionGroup = (resourceType: EvaluationResourceType): JudgmentConditionGroup => ({
|
||||
id: createId('group'),
|
||||
logicalOperator: 'and',
|
||||
items: [buildConditionItem(resourceType)],
|
||||
})
|
||||
|
||||
export const buildInitialState = (resourceType: EvaluationResourceType): EvaluationResourceState => {
|
||||
return {
|
||||
judgeModelId: null,
|
||||
metrics: [],
|
||||
conditions: [createConditionGroup(resourceType)],
|
||||
activeBatchTab: 'input-fields',
|
||||
uploadedFileName: null,
|
||||
batchRecords: [],
|
||||
}
|
||||
}
|
||||
|
||||
export const getResourceState = (
|
||||
resources: EvaluationStoreResources,
|
||||
resourceType: EvaluationResourceType,
|
||||
resourceId: string,
|
||||
) => {
|
||||
const resourceKey = buildResourceKey(resourceType, resourceId)
|
||||
|
||||
return {
|
||||
resourceKey,
|
||||
resource: resources[resourceKey] ?? buildInitialState(resourceType),
|
||||
}
|
||||
}
|
||||
|
||||
export const updateResourceState = (
|
||||
resources: EvaluationStoreResources,
|
||||
resourceType: EvaluationResourceType,
|
||||
resourceId: string,
|
||||
updater: (resource: EvaluationResourceState) => EvaluationResourceState,
|
||||
) => {
|
||||
const { resource, resourceKey } = getResourceState(resources, resourceType, resourceId)
|
||||
|
||||
return {
|
||||
...resources,
|
||||
[resourceKey]: updater(resource),
|
||||
}
|
||||
}
|
||||
|
||||
export const updateMetric = (
|
||||
metrics: EvaluationMetric[],
|
||||
metricId: string,
|
||||
updater: (metric: EvaluationMetric) => EvaluationMetric,
|
||||
) => metrics.map(metric => metric.id === metricId ? updater(metric) : metric)
|
||||
|
||||
export const updateConditionGroup = (
|
||||
groups: JudgmentConditionGroup[],
|
||||
groupId: string,
|
||||
updater: (group: JudgmentConditionGroup) => JudgmentConditionGroup,
|
||||
) => groups.map(group => group.id === groupId ? updater(group) : group)
|
||||
|
||||
export const createBatchTestRecord = (
|
||||
resourceType: EvaluationResourceType,
|
||||
uploadedFileName: string | null | undefined,
|
||||
): BatchTestRecord => {
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
|
||||
return {
|
||||
id: createId('batch'),
|
||||
fileName: uploadedFileName ?? config.templateFileName,
|
||||
status: 'running',
|
||||
startedAt: new Date().toLocaleTimeString(),
|
||||
summary: config.historySummaryLabel,
|
||||
}
|
||||
}
|
||||
|
||||
export const isCustomMetricConfigured = (metric: EvaluationMetric) => {
|
||||
if (metric.kind !== 'custom-workflow')
|
||||
return true
|
||||
|
||||
if (!metric.customConfig?.workflowId)
|
||||
return false
|
||||
|
||||
return metric.customConfig.mappings.length > 0
|
||||
&& metric.customConfig.mappings.every(mapping => !!mapping.sourceFieldId && !!mapping.targetVariableId)
|
||||
}
|
||||
|
||||
export const isEvaluationRunnable = (state: EvaluationResourceState) => {
|
||||
return !!state.judgeModelId
|
||||
&& state.metrics.length > 0
|
||||
&& state.metrics.every(isCustomMetricConfigured)
|
||||
&& state.conditions.some(group => group.items.length > 0)
|
||||
}
|
||||
|
||||
export const getAllowedOperators = (resourceType: EvaluationResourceType, fieldId: string | null) => {
|
||||
const field = getEvaluationMockConfig(resourceType).fieldOptions.find(option => option.id === fieldId)
|
||||
|
||||
if (!field)
|
||||
return ['contains'] as ComparisonOperator[]
|
||||
|
||||
return getComparisonOperators(field.type)
|
||||
}
|
||||
367
web/app/components/evaluation/store.ts
Normal file
367
web/app/components/evaluation/store.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import type {
|
||||
ComparisonOperator,
|
||||
EvaluationResourceState,
|
||||
EvaluationResourceType,
|
||||
} from './types'
|
||||
import type { NodeInfo } from '@/types/evaluation'
|
||||
import { create } from 'zustand'
|
||||
import { getDefaultOperator, getEvaluationMockConfig } from './mock'
|
||||
import {
|
||||
buildConditionItem,
|
||||
buildInitialState,
|
||||
buildResourceKey,
|
||||
createBatchTestRecord,
|
||||
createBuiltinMetric,
|
||||
createConditionGroup,
|
||||
createCustomMetric,
|
||||
createCustomMetricMapping,
|
||||
getAllowedOperators as getAllowedOperatorsFromUtils,
|
||||
getConditionValue,
|
||||
isCustomMetricConfigured as isCustomMetricConfiguredFromUtils,
|
||||
isEvaluationRunnable as isEvaluationRunnableFromUtils,
|
||||
requiresConditionValue as requiresConditionValueFromUtils,
|
||||
updateConditionGroup,
|
||||
updateMetric,
|
||||
updateResourceState,
|
||||
} from './store-utils'
|
||||
|
||||
type EvaluationStore = {
|
||||
resources: Record<string, EvaluationResourceState>
|
||||
ensureResource: (resourceType: EvaluationResourceType, resourceId: string) => void
|
||||
setJudgeModel: (resourceType: EvaluationResourceType, resourceId: string, judgeModelId: string) => void
|
||||
addBuiltinMetric: (resourceType: EvaluationResourceType, resourceId: string, optionId: string, nodeInfoList?: NodeInfo[]) => void
|
||||
addCustomMetric: (resourceType: EvaluationResourceType, resourceId: string) => void
|
||||
removeMetric: (resourceType: EvaluationResourceType, resourceId: string, metricId: string) => void
|
||||
setCustomMetricWorkflow: (resourceType: EvaluationResourceType, resourceId: string, metricId: string, workflowId: string) => void
|
||||
addCustomMetricMapping: (resourceType: EvaluationResourceType, resourceId: string, metricId: string) => void
|
||||
updateCustomMetricMapping: (
|
||||
resourceType: EvaluationResourceType,
|
||||
resourceId: string,
|
||||
metricId: string,
|
||||
mappingId: string,
|
||||
patch: { sourceFieldId?: string | null, targetVariableId?: string | null },
|
||||
) => void
|
||||
removeCustomMetricMapping: (resourceType: EvaluationResourceType, resourceId: string, metricId: string, mappingId: string) => void
|
||||
addConditionGroup: (resourceType: EvaluationResourceType, resourceId: string) => void
|
||||
removeConditionGroup: (resourceType: EvaluationResourceType, resourceId: string, groupId: string) => void
|
||||
setConditionGroupOperator: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, logicalOperator: 'and' | 'or') => void
|
||||
addConditionItem: (resourceType: EvaluationResourceType, resourceId: string, groupId: string) => void
|
||||
removeConditionItem: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, itemId: string) => void
|
||||
updateConditionField: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, itemId: string, fieldId: string) => void
|
||||
updateConditionOperator: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, itemId: string, operator: ComparisonOperator) => void
|
||||
updateConditionValue: (
|
||||
resourceType: EvaluationResourceType,
|
||||
resourceId: string,
|
||||
groupId: string,
|
||||
itemId: string,
|
||||
value: string | number | boolean | null,
|
||||
) => void
|
||||
setBatchTab: (resourceType: EvaluationResourceType, resourceId: string, tab: EvaluationResourceState['activeBatchTab']) => void
|
||||
setUploadedFileName: (resourceType: EvaluationResourceType, resourceId: string, uploadedFileName: string | null) => void
|
||||
runBatchTest: (resourceType: EvaluationResourceType, resourceId: string) => void
|
||||
}
|
||||
|
||||
const initialResourceCache: Record<string, EvaluationResourceState> = {}
|
||||
|
||||
export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
|
||||
resources: {},
|
||||
ensureResource: (resourceType, resourceId) => {
|
||||
const resourceKey = buildResourceKey(resourceType, resourceId)
|
||||
if (get().resources[resourceKey])
|
||||
return
|
||||
|
||||
set(state => ({
|
||||
resources: {
|
||||
...state.resources,
|
||||
[resourceKey]: buildInitialState(resourceType),
|
||||
},
|
||||
}))
|
||||
},
|
||||
setJudgeModel: (resourceType, resourceId, judgeModelId) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
judgeModelId,
|
||||
})),
|
||||
}))
|
||||
},
|
||||
addBuiltinMetric: (resourceType, resourceId, optionId, nodeInfoList = []) => {
|
||||
const option = getEvaluationMockConfig(resourceType).builtinMetrics.find(metric => metric.id === optionId)
|
||||
if (!option)
|
||||
return
|
||||
|
||||
set((state) => {
|
||||
return {
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, currentResource => ({
|
||||
...currentResource,
|
||||
metrics: currentResource.metrics.some(metric => metric.optionId === optionId && metric.kind === 'builtin')
|
||||
? currentResource.metrics.map(metric => metric.optionId === optionId && metric.kind === 'builtin'
|
||||
? {
|
||||
...metric,
|
||||
nodeInfoList,
|
||||
}
|
||||
: metric)
|
||||
: [...currentResource.metrics, createBuiltinMetric(option, nodeInfoList)],
|
||||
})),
|
||||
}
|
||||
})
|
||||
},
|
||||
addCustomMetric: (resourceType, resourceId) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
metrics: [...resource.metrics, createCustomMetric()],
|
||||
})),
|
||||
}))
|
||||
},
|
||||
removeMetric: (resourceType, resourceId, metricId) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
metrics: resource.metrics.filter(metric => metric.id !== metricId),
|
||||
})),
|
||||
}))
|
||||
},
|
||||
setCustomMetricWorkflow: (resourceType, resourceId, metricId, workflowId) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
metrics: updateMetric(resource.metrics, metricId, metric => ({
|
||||
...metric,
|
||||
customConfig: metric.customConfig
|
||||
? {
|
||||
...metric.customConfig,
|
||||
workflowId,
|
||||
mappings: metric.customConfig.mappings.map(mapping => ({
|
||||
...mapping,
|
||||
targetVariableId: null,
|
||||
})),
|
||||
}
|
||||
: metric.customConfig,
|
||||
})),
|
||||
})),
|
||||
}))
|
||||
},
|
||||
addCustomMetricMapping: (resourceType, resourceId, metricId) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
metrics: updateMetric(resource.metrics, metricId, metric => ({
|
||||
...metric,
|
||||
customConfig: metric.customConfig
|
||||
? {
|
||||
...metric.customConfig,
|
||||
mappings: [...metric.customConfig.mappings, createCustomMetricMapping()],
|
||||
}
|
||||
: metric.customConfig,
|
||||
})),
|
||||
})),
|
||||
}))
|
||||
},
|
||||
updateCustomMetricMapping: (resourceType, resourceId, metricId, mappingId, patch) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
metrics: updateMetric(resource.metrics, metricId, metric => ({
|
||||
...metric,
|
||||
customConfig: metric.customConfig
|
||||
? {
|
||||
...metric.customConfig,
|
||||
mappings: metric.customConfig.mappings.map(mapping => mapping.id === mappingId ? { ...mapping, ...patch } : mapping),
|
||||
}
|
||||
: metric.customConfig,
|
||||
})),
|
||||
})),
|
||||
}))
|
||||
},
|
||||
removeCustomMetricMapping: (resourceType, resourceId, metricId, mappingId) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
metrics: updateMetric(resource.metrics, metricId, metric => ({
|
||||
...metric,
|
||||
customConfig: metric.customConfig
|
||||
? {
|
||||
...metric.customConfig,
|
||||
mappings: metric.customConfig.mappings.filter(mapping => mapping.id !== mappingId),
|
||||
}
|
||||
: metric.customConfig,
|
||||
})),
|
||||
})),
|
||||
}))
|
||||
},
|
||||
addConditionGroup: (resourceType, resourceId) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
conditions: [...resource.conditions, createConditionGroup(resourceType)],
|
||||
})),
|
||||
}))
|
||||
},
|
||||
removeConditionGroup: (resourceType, resourceId, groupId) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
conditions: resource.conditions.filter(group => group.id !== groupId),
|
||||
})),
|
||||
}))
|
||||
},
|
||||
setConditionGroupOperator: (resourceType, resourceId, groupId, logicalOperator) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
|
||||
...group,
|
||||
logicalOperator,
|
||||
})),
|
||||
})),
|
||||
}))
|
||||
},
|
||||
addConditionItem: (resourceType, resourceId, groupId) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
|
||||
...group,
|
||||
items: [...group.items, buildConditionItem(resourceType)],
|
||||
})),
|
||||
})),
|
||||
}))
|
||||
},
|
||||
removeConditionItem: (resourceType, resourceId, groupId, itemId) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
|
||||
...group,
|
||||
items: group.items.filter(item => item.id !== itemId),
|
||||
})),
|
||||
})),
|
||||
}))
|
||||
},
|
||||
updateConditionField: (resourceType, resourceId, groupId, itemId, fieldId) => {
|
||||
const field = getEvaluationMockConfig(resourceType).fieldOptions.find(option => option.id === fieldId)
|
||||
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
|
||||
...group,
|
||||
items: group.items.map((item) => {
|
||||
if (item.id !== itemId)
|
||||
return item
|
||||
|
||||
const nextOperator = field ? getDefaultOperator(field.type) : item.operator
|
||||
|
||||
return {
|
||||
...item,
|
||||
fieldId,
|
||||
operator: nextOperator,
|
||||
value: getConditionValue(field, nextOperator),
|
||||
}
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
}))
|
||||
},
|
||||
updateConditionOperator: (resourceType, resourceId, groupId, itemId, operator) => {
|
||||
set((state) => {
|
||||
const fieldOptions = getEvaluationMockConfig(resourceType).fieldOptions
|
||||
|
||||
return {
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, currentResource => ({
|
||||
...currentResource,
|
||||
conditions: updateConditionGroup(currentResource.conditions, groupId, group => ({
|
||||
...group,
|
||||
items: group.items.map((item) => {
|
||||
if (item.id !== itemId)
|
||||
return item
|
||||
|
||||
const field = fieldOptions.find(option => option.id === item.fieldId)
|
||||
|
||||
return {
|
||||
...item,
|
||||
operator,
|
||||
value: getConditionValue(field, operator, item.value),
|
||||
}
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
}
|
||||
})
|
||||
},
|
||||
updateConditionValue: (resourceType, resourceId, groupId, itemId, value) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
|
||||
...group,
|
||||
items: group.items.map(item => item.id === itemId ? { ...item, value } : item),
|
||||
})),
|
||||
})),
|
||||
}))
|
||||
},
|
||||
setBatchTab: (resourceType, resourceId, tab) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
activeBatchTab: tab,
|
||||
})),
|
||||
}))
|
||||
},
|
||||
setUploadedFileName: (resourceType, resourceId, uploadedFileName) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
uploadedFileName,
|
||||
})),
|
||||
}))
|
||||
},
|
||||
runBatchTest: (resourceType, resourceId) => {
|
||||
const { uploadedFileName } = get().resources[buildResourceKey(resourceType, resourceId)] ?? buildInitialState(resourceType)
|
||||
const nextRecord = createBatchTestRecord(resourceType, uploadedFileName)
|
||||
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
activeBatchTab: 'history',
|
||||
batchRecords: [nextRecord, ...resource.batchRecords],
|
||||
})),
|
||||
}))
|
||||
|
||||
window.setTimeout(() => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
batchRecords: resource.batchRecords.map(record => record.id === nextRecord.id
|
||||
? {
|
||||
...record,
|
||||
status: resource.metrics.length > 1 ? 'success' : 'failed',
|
||||
}
|
||||
: record),
|
||||
})),
|
||||
}))
|
||||
}, 1200)
|
||||
},
|
||||
}))
|
||||
|
||||
export const useEvaluationResource = (resourceType: EvaluationResourceType, resourceId: string) => {
|
||||
const resourceKey = buildResourceKey(resourceType, resourceId)
|
||||
return useEvaluationStore(state => state.resources[resourceKey] ?? (initialResourceCache[resourceKey] ??= buildInitialState(resourceType)))
|
||||
}
|
||||
|
||||
export const getAllowedOperators = (resourceType: EvaluationResourceType, fieldId: string | null) => {
|
||||
return getAllowedOperatorsFromUtils(resourceType, fieldId)
|
||||
}
|
||||
|
||||
export const isCustomMetricConfigured = (metric: EvaluationResourceState['metrics'][number]) => {
|
||||
return isCustomMetricConfiguredFromUtils(metric)
|
||||
}
|
||||
|
||||
export const isEvaluationRunnable = (state: EvaluationResourceState) => {
|
||||
return isEvaluationRunnableFromUtils(state)
|
||||
}
|
||||
|
||||
export const requiresConditionValue = (operator: ComparisonOperator) => {
|
||||
return requiresConditionValueFromUtils(operator)
|
||||
}
|
||||
125
web/app/components/evaluation/types.ts
Normal file
125
web/app/components/evaluation/types.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { NodeInfo } from '@/types/evaluation'
|
||||
|
||||
export type EvaluationResourceType = 'workflow' | 'pipeline' | 'snippet'
|
||||
|
||||
export type EvaluationResourceProps = {
|
||||
resourceType: EvaluationResourceType
|
||||
resourceId: string
|
||||
}
|
||||
|
||||
export type MetricKind = 'builtin' | 'custom-workflow'
|
||||
|
||||
export type BatchTestTab = 'input-fields' | 'history'
|
||||
|
||||
export type FieldType = 'string' | 'number' | 'boolean' | 'enum' | 'time'
|
||||
|
||||
export type ComparisonOperator
|
||||
= | 'contains'
|
||||
| 'not_contains'
|
||||
| 'is'
|
||||
| 'is_not'
|
||||
| 'is_empty'
|
||||
| 'is_not_empty'
|
||||
| 'greater_than'
|
||||
| 'less_than'
|
||||
| 'greater_or_equal'
|
||||
| 'less_or_equal'
|
||||
| 'before'
|
||||
| 'after'
|
||||
|
||||
export type JudgeModelOption = {
|
||||
id: string
|
||||
label: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
export type MetricOption = {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
group: string
|
||||
badges: string[]
|
||||
}
|
||||
|
||||
export type EvaluationWorkflowOption = {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
targetVariables: Array<{
|
||||
id: string
|
||||
label: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type EvaluationFieldOption = {
|
||||
id: string
|
||||
label: string
|
||||
group: string
|
||||
type: FieldType
|
||||
options?: Array<{
|
||||
value: string
|
||||
label: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type CustomMetricMapping = {
|
||||
id: string
|
||||
sourceFieldId: string | null
|
||||
targetVariableId: string | null
|
||||
}
|
||||
|
||||
export type CustomMetricConfig = {
|
||||
workflowId: string | null
|
||||
mappings: CustomMetricMapping[]
|
||||
}
|
||||
|
||||
export type EvaluationMetric = {
|
||||
id: string
|
||||
optionId: string
|
||||
kind: MetricKind
|
||||
label: string
|
||||
description: string
|
||||
badges: string[]
|
||||
nodeInfoList?: NodeInfo[]
|
||||
customConfig?: CustomMetricConfig
|
||||
}
|
||||
|
||||
export type JudgmentConditionItem = {
|
||||
id: string
|
||||
fieldId: string | null
|
||||
operator: ComparisonOperator
|
||||
value: string | number | boolean | null
|
||||
}
|
||||
|
||||
export type JudgmentConditionGroup = {
|
||||
id: string
|
||||
logicalOperator: 'and' | 'or'
|
||||
items: JudgmentConditionItem[]
|
||||
}
|
||||
|
||||
export type BatchTestRecord = {
|
||||
id: string
|
||||
fileName: string
|
||||
status: 'running' | 'success' | 'failed'
|
||||
startedAt: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
export type EvaluationResourceState = {
|
||||
judgeModelId: string | null
|
||||
metrics: EvaluationMetric[]
|
||||
conditions: JudgmentConditionGroup[]
|
||||
activeBatchTab: BatchTestTab
|
||||
uploadedFileName: string | null
|
||||
batchRecords: BatchTestRecord[]
|
||||
}
|
||||
|
||||
export type EvaluationMockConfig = {
|
||||
judgeModels: JudgeModelOption[]
|
||||
builtinMetrics: MetricOption[]
|
||||
workflowOptions: EvaluationWorkflowOption[]
|
||||
fieldOptions: EvaluationFieldOption[]
|
||||
templateFileName: string
|
||||
batchRequirements: string[]
|
||||
historySummaryLabel: string
|
||||
}
|
||||
60
web/app/components/evaluation/utils.ts
Normal file
60
web/app/components/evaluation/utils.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { ComparisonOperator, EvaluationFieldOption } from './types'
|
||||
|
||||
export const TAB_CLASS_NAME = 'flex-1 rounded-lg px-3 py-2 text-left system-sm-medium'
|
||||
|
||||
const compactOperatorLabels: Partial<Record<ComparisonOperator, string>> = {
|
||||
is: '=',
|
||||
is_not: '!=',
|
||||
greater_than: '>',
|
||||
less_than: '<',
|
||||
greater_or_equal: '>=',
|
||||
less_or_equal: '<=',
|
||||
}
|
||||
|
||||
export const encodeModelSelection = (provider: string, model: string) => `${provider}::${model}`
|
||||
|
||||
export const decodeModelSelection = (judgeModelId: string | null) => {
|
||||
if (!judgeModelId)
|
||||
return undefined
|
||||
|
||||
const [provider, model] = judgeModelId.split('::')
|
||||
if (!provider || !model)
|
||||
return undefined
|
||||
|
||||
return { provider, model }
|
||||
}
|
||||
|
||||
export const groupFieldOptions = (fieldOptions: EvaluationFieldOption[]) => {
|
||||
return Object.entries(fieldOptions.reduce<Record<string, EvaluationFieldOption[]>>((acc, field) => {
|
||||
acc[field.group] = [...(acc[field.group] ?? []), field]
|
||||
return acc
|
||||
}, {}))
|
||||
}
|
||||
|
||||
export const getOperatorLabel = (
|
||||
operator: ComparisonOperator,
|
||||
fieldType: EvaluationFieldOption['type'] | undefined,
|
||||
t: TFunction<'evaluation'>,
|
||||
) => {
|
||||
if (fieldType === 'number' && compactOperatorLabels[operator])
|
||||
return compactOperatorLabels[operator] as string
|
||||
|
||||
return t(`conditions.operators.${operator}` as const)
|
||||
}
|
||||
|
||||
export const getFieldTypeIconClassName = (fieldType: EvaluationFieldOption['type']) => {
|
||||
if (fieldType === 'number')
|
||||
return 'i-ri-hashtag'
|
||||
|
||||
if (fieldType === 'boolean')
|
||||
return 'i-ri-checkbox-circle-line'
|
||||
|
||||
if (fieldType === 'enum')
|
||||
return 'i-ri-list-check-2'
|
||||
|
||||
if (fieldType === 'time')
|
||||
return 'i-ri-time-line'
|
||||
|
||||
return 'i-ri-text'
|
||||
}
|
||||
@@ -107,7 +107,7 @@ const AppNav = () => {
|
||||
icon={<RiRobot2Line className="h-4 w-4" />}
|
||||
activeIcon={<RiRobot2Fill className="h-4 w-4" />}
|
||||
text={t('menus.apps', { ns: 'common' })}
|
||||
activeSegment={['apps', 'app']}
|
||||
activeSegment={['apps', 'app', 'snippets']}
|
||||
link="/apps"
|
||||
curNav={appDetail}
|
||||
navigationItems={navItems}
|
||||
|
||||
@@ -14,7 +14,7 @@ const HeaderWrapper = ({
|
||||
children,
|
||||
}: HeaderWrapperProps) => {
|
||||
const pathname = usePathname()
|
||||
const isBordered = ['/apps', '/datasets/create', '/tools'].includes(pathname)
|
||||
const isBordered = ['/apps', '/snippets', '/datasets/create', '/tools'].includes(pathname)
|
||||
// Check if the current path is a workflow canvas & fullscreen
|
||||
const inWorkflowCanvas = pathname.endsWith('/workflow')
|
||||
const isPipelineCanvas = pathname.endsWith('/pipeline')
|
||||
|
||||
139
web/app/components/snippets/__tests__/index.spec.tsx
Normal file
139
web/app/components/snippets/__tests__/index.spec.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { SnippetDetailPayload } from '@/models/snippet'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import SnippetPage from '..'
|
||||
|
||||
const mockUseSnippetInit = vi.fn()
|
||||
const mockSetAppSidebarExpand = vi.fn()
|
||||
|
||||
vi.mock('../hooks/use-snippet-init', () => ({
|
||||
useSnippetInit: (snippetId: string) => mockUseSnippetInit(snippetId),
|
||||
}))
|
||||
|
||||
vi.mock('../components/snippet-main', () => ({
|
||||
default: ({ snippetId }: { snippetId: string }) => <div data-testid="snippet-main">{snippetId}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
replace: vi.fn(),
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
default: () => 'desktop',
|
||||
MediaType: { mobile: 'mobile', desktop: 'desktop' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: { setAppSidebarExpand: typeof mockSetAppSidebarExpand }) => unknown) => selector({
|
||||
setAppSidebarExpand: mockSetAppSidebarExpand,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="workflow-default-context">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/context', () => ({
|
||||
WorkflowContextProvider: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="workflow-context-provider">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
initialNodes: (nodes: unknown[]) => nodes,
|
||||
initialEdges: (edges: unknown[]) => edges,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/app-sidebar', () => ({
|
||||
default: ({
|
||||
renderHeader,
|
||||
renderNavigation,
|
||||
}: {
|
||||
renderHeader?: (modeState: string) => React.ReactNode
|
||||
renderNavigation?: (modeState: string) => React.ReactNode
|
||||
}) => (
|
||||
<div data-testid="app-sidebar">
|
||||
<div data-testid="app-sidebar-header">{renderHeader?.('expand')}</div>
|
||||
<div data-testid="app-sidebar-navigation">{renderNavigation?.('expand')}</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app-sidebar/nav-link', () => ({
|
||||
default: ({ name, onClick }: { name: string, onClick?: () => void }) => (
|
||||
<button type="button" onClick={onClick}>{name}</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app-sidebar/snippet-info', () => ({
|
||||
default: () => <div data-testid="snippet-info" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/evaluation', () => ({
|
||||
default: ({ resourceId }: { resourceId: string }) => <div data-testid="evaluation">{resourceId}</div>,
|
||||
}))
|
||||
|
||||
const mockSnippetDetail: SnippetDetailPayload = {
|
||||
snippet: {
|
||||
id: 'snippet-1',
|
||||
name: 'Tone Rewriter',
|
||||
description: 'A static snippet mock.',
|
||||
author: 'Evan',
|
||||
updatedAt: 'Updated 2h ago',
|
||||
usage: 'Used 19 times',
|
||||
icon: '🪄',
|
||||
iconBackground: '#E0EAFF',
|
||||
status: 'Draft',
|
||||
},
|
||||
graph: {
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
nodes: [],
|
||||
edges: [],
|
||||
},
|
||||
inputFields: [],
|
||||
uiMeta: {
|
||||
inputFieldCount: 0,
|
||||
checklistCount: 0,
|
||||
autoSavedAt: 'Auto-saved · a few seconds ago',
|
||||
},
|
||||
}
|
||||
|
||||
describe('SnippetPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseSnippetInit.mockReturnValue({
|
||||
data: mockSnippetDetail,
|
||||
isLoading: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should render the orchestrate route shell with independent main content', () => {
|
||||
render(<SnippetPage snippetId="snippet-1" />)
|
||||
|
||||
expect(screen.getByTestId('app-sidebar')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('snippet-info')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('workflow-default-context')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('snippet-main')).toHaveTextContent('snippet-1')
|
||||
})
|
||||
|
||||
it('should render loading fallback when orchestrate data is unavailable', () => {
|
||||
mockUseSnippetInit.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<SnippetPage snippetId="missing-snippet" />)
|
||||
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { SnippetDetailPayload } from '@/models/snippet'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import SnippetEvaluationPage from '../snippet-evaluation-page'
|
||||
|
||||
const mockUseSnippetApiDetail = vi.fn()
|
||||
const mockGetSnippetDetailMock = vi.fn()
|
||||
const mockSetAppSidebarExpand = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-snippets', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/use-snippets')>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useSnippetApiDetail: (snippetId: string) => mockUseSnippetApiDetail(snippetId),
|
||||
useUpdateSnippetMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useExportSnippetMutation: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteSnippetMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
replace: vi.fn(),
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippets.mock', () => ({
|
||||
getSnippetDetailMock: (snippetId: string) => mockGetSnippetDetailMock(snippetId),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-breakpoints', () => ({
|
||||
default: () => 'desktop',
|
||||
MediaType: { mobile: 'mobile', desktop: 'desktop' },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: { setAppSidebarExpand: typeof mockSetAppSidebarExpand }) => unknown) => selector({
|
||||
setAppSidebarExpand: mockSetAppSidebarExpand,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app-sidebar', () => ({
|
||||
default: ({
|
||||
renderHeader,
|
||||
renderNavigation,
|
||||
}: {
|
||||
renderHeader?: (modeState: string) => React.ReactNode
|
||||
renderNavigation?: (modeState: string) => React.ReactNode
|
||||
}) => (
|
||||
<div data-testid="app-sidebar">
|
||||
<div data-testid="app-sidebar-header">{renderHeader?.('expand')}</div>
|
||||
<div data-testid="app-sidebar-navigation">{renderNavigation?.('expand')}</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app-sidebar/nav-link', () => ({
|
||||
default: ({ name, onClick }: { name: string, onClick?: () => void }) => (
|
||||
<button type="button" onClick={onClick}>{name}</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/evaluation', () => ({
|
||||
default: ({ resourceId }: { resourceId: string }) => <div data-testid="evaluation">{resourceId}</div>,
|
||||
}))
|
||||
|
||||
const mockSnippetDetail: SnippetDetailPayload = {
|
||||
snippet: {
|
||||
id: 'snippet-1',
|
||||
name: 'Tone Rewriter',
|
||||
description: 'A static snippet mock.',
|
||||
author: 'Evan',
|
||||
updatedAt: '2024-03-24',
|
||||
usage: '19',
|
||||
icon: '🪄',
|
||||
iconBackground: '#E0EAFF',
|
||||
},
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
zoom: 1,
|
||||
},
|
||||
},
|
||||
inputFields: [],
|
||||
uiMeta: {
|
||||
inputFieldCount: 0,
|
||||
checklistCount: 0,
|
||||
autoSavedAt: '2024-03-24 12:00',
|
||||
},
|
||||
}
|
||||
|
||||
describe('SnippetEvaluationPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseSnippetApiDetail.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
})
|
||||
mockGetSnippetDetailMock.mockReturnValue(mockSnippetDetail)
|
||||
})
|
||||
|
||||
it('should render evaluation with mock snippet detail data', () => {
|
||||
render(<SnippetEvaluationPage snippetId="snippet-1" />)
|
||||
|
||||
expect(mockGetSnippetDetailMock).toHaveBeenCalledWith('snippet-1')
|
||||
expect(mockUseSnippetApiDetail).not.toHaveBeenCalled()
|
||||
expect(screen.getByTestId('app-sidebar')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('evaluation')).toHaveTextContent('snippet-1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { InputVar } from '@/models/pipeline'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import SnippetInputFieldEditor from '../input-field-editor'
|
||||
|
||||
const mockUseFloatingRight = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/rag-pipeline/components/panel/input-field/hooks', () => ({
|
||||
useFloatingRight: (...args: unknown[]) => mockUseFloatingRight(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/rag-pipeline/components/panel/input-field/editor/form', () => ({
|
||||
default: ({ isEditMode }: { isEditMode: boolean }) => (
|
||||
<div data-testid="snippet-input-field-form">{isEditMode ? 'edit' : 'create'}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createField = (overrides: Partial<InputVar> = {}): InputVar => ({
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'Blog URL',
|
||||
variable: 'blog_url',
|
||||
required: true,
|
||||
options: [],
|
||||
placeholder: 'Paste a source article URL',
|
||||
max_length: 256,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('SnippetInputFieldEditor', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseFloatingRight.mockReturnValue({
|
||||
floatingRight: false,
|
||||
floatingRightWidth: 400,
|
||||
})
|
||||
})
|
||||
|
||||
// Verifies the default desktop layout keeps the editor inline with the panel.
|
||||
describe('Rendering', () => {
|
||||
it('should render the add title without floating positioning by default', () => {
|
||||
render(
|
||||
<SnippetInputFieldEditor
|
||||
onClose={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const title = screen.getByText('datasetPipeline.inputFieldPanel.addInputField')
|
||||
const editor = title.parentElement
|
||||
|
||||
expect(title).toBeInTheDocument()
|
||||
expect(editor).not.toHaveClass('absolute')
|
||||
expect(editor).toHaveStyle({ width: 'min(400px, calc(100vw - 24px))' })
|
||||
expect(mockUseFloatingRight).toHaveBeenCalledWith(400)
|
||||
})
|
||||
|
||||
it('should float over the panel when there is not enough room', () => {
|
||||
mockUseFloatingRight.mockReturnValue({
|
||||
floatingRight: true,
|
||||
floatingRightWidth: 320,
|
||||
})
|
||||
|
||||
render(
|
||||
<SnippetInputFieldEditor
|
||||
field={createField()}
|
||||
onClose={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const title = screen.getByText('datasetPipeline.inputFieldPanel.editInputField')
|
||||
const editor = title.parentElement
|
||||
|
||||
expect(title).toBeInTheDocument()
|
||||
expect(editor).toHaveClass('absolute', 'right-0', 'z-[100]')
|
||||
expect(editor).toHaveStyle({ width: 'min(320px, calc(100vw - 24px))' })
|
||||
expect(screen.getByTestId('snippet-input-field-form')).toHaveTextContent('edit')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import PublishMenu from '../publish-menu'
|
||||
|
||||
describe('PublishMenu', () => {
|
||||
it('should render the draft summary and publish shortcut', () => {
|
||||
const { container } = render(
|
||||
<PublishMenu
|
||||
uiMeta={{
|
||||
inputFieldCount: 1,
|
||||
checklistCount: 2,
|
||||
autoSavedAt: 'Auto-saved · a few seconds ago',
|
||||
}}
|
||||
onPublish={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('snippet.publishMenuCurrentDraft')).toBeInTheDocument()
|
||||
expect(screen.getByText('Auto-saved · a few seconds ago')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'snippet.publishButton' })).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('.system-kbd')).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import SnippetCreateCard from '../snippet-create-card'
|
||||
|
||||
const { mockPush, mockCreateMutate, mockToastSuccess, mockToastError } = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockCreateMutate: vi.fn(),
|
||||
mockToastSuccess: vi.fn(),
|
||||
mockToastError: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
success: mockToastSuccess,
|
||||
error: mockToastError,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippets', () => ({
|
||||
useCreateSnippetMutation: () => ({
|
||||
mutate: mockCreateMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../snippet-import-dsl-dialog', () => ({
|
||||
default: ({ show, onClose, onSuccess }: { show: boolean, onClose: () => void, onSuccess?: (snippetId: string) => void }) => {
|
||||
if (!show)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div data-testid="snippet-import-dsl-dialog">
|
||||
<button type="button" onClick={() => onSuccess?.('snippet-imported')}>Complete Import</button>
|
||||
<button type="button" onClick={onClose}>Close Import</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
describe('SnippetCreateCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Create From Blank', () => {
|
||||
it('should open the create dialog and create a snippet from the modal', async () => {
|
||||
mockCreateMutate.mockImplementation((_payload, options?: { onSuccess?: (snippet: { id: string }) => void }) => {
|
||||
options?.onSuccess?.({ id: 'snippet-123' })
|
||||
})
|
||||
|
||||
render(<SnippetCreateCard />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'snippet.createFromBlank' }))
|
||||
expect(screen.getByText('workflow.snippet.createDialogTitle')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.namePlaceholder'), {
|
||||
target: { value: 'My Snippet' },
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.descriptionPlaceholder'), {
|
||||
target: { value: 'Useful snippet description' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /workflow\.snippet\.confirm/i }))
|
||||
|
||||
expect(mockCreateMutate).toHaveBeenCalledWith({
|
||||
body: {
|
||||
name: 'My Snippet',
|
||||
description: 'Useful snippet description',
|
||||
icon_info: {
|
||||
icon: '🤖',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: undefined,
|
||||
},
|
||||
},
|
||||
}, expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
onError: expect.any(Function),
|
||||
}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-123/orchestrate')
|
||||
})
|
||||
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('workflow.snippet.createSuccess')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Import DSL', () => {
|
||||
it('should open the import dialog and navigate when the import succeeds', async () => {
|
||||
render(<SnippetCreateCard />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.importDSL' }))
|
||||
expect(screen.getByTestId('snippet-import-dsl-dialog')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Complete Import' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-imported/orchestrate')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,305 @@
|
||||
import type { WorkflowProps } from '@/app/components/workflow'
|
||||
import type { SnippetDetailPayload, SnippetInputField } from '@/models/snippet'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import SnippetMain from '../snippet-main'
|
||||
|
||||
const mockSyncInputFieldsDraft = vi.fn()
|
||||
const mockCloseEditor = vi.fn()
|
||||
const mockOpenEditor = vi.fn()
|
||||
const mockReset = vi.fn()
|
||||
const mockSetInputPanelOpen = vi.fn()
|
||||
const mockSetPublishMenuOpen = vi.fn()
|
||||
const mockToggleInputPanel = vi.fn()
|
||||
const mockTogglePublishMenu = vi.fn()
|
||||
const mockPublishSnippetMutateAsync = vi.fn()
|
||||
const mockFetchInspectVars = vi.fn()
|
||||
const mockHandleBackupDraft = vi.fn()
|
||||
const mockHandleLoadBackupDraft = vi.fn()
|
||||
const mockHandleRestoreFromPublishedWorkflow = vi.fn()
|
||||
const mockHandleRun = vi.fn()
|
||||
const mockHandleStartWorkflowRun = vi.fn()
|
||||
const mockHandleStopRun = vi.fn()
|
||||
const mockHandleWorkflowStartRunInWorkflow = vi.fn()
|
||||
const mockInspectVarsCrud = {
|
||||
hasNodeInspectVars: vi.fn(),
|
||||
hasSetInspectVar: vi.fn(),
|
||||
fetchInspectVarValue: vi.fn(),
|
||||
editInspectVarValue: vi.fn(),
|
||||
renameInspectVarName: vi.fn(),
|
||||
appendNodeInspectVars: vi.fn(),
|
||||
deleteInspectVar: vi.fn(),
|
||||
deleteNodeInspectorVars: vi.fn(),
|
||||
deleteAllInspectorVars: vi.fn(),
|
||||
isInspectVarEdited: vi.fn(),
|
||||
resetToLastRunVar: vi.fn(),
|
||||
invalidateSysVarValues: vi.fn(),
|
||||
resetConversationVar: vi.fn(),
|
||||
invalidateConversationVarValues: vi.fn(),
|
||||
}
|
||||
let capturedHooksStore: Record<string, unknown> | undefined
|
||||
|
||||
vi.mock('@/app/components/snippets/store', () => ({
|
||||
useSnippetDetailStore: (selector: (state: {
|
||||
editingField: SnippetInputField | null
|
||||
isEditorOpen: boolean
|
||||
isInputPanelOpen: boolean
|
||||
isPublishMenuOpen: boolean
|
||||
closeEditor: typeof mockCloseEditor
|
||||
openEditor: typeof mockOpenEditor
|
||||
reset: typeof mockReset
|
||||
setInputPanelOpen: typeof mockSetInputPanelOpen
|
||||
setPublishMenuOpen: typeof mockSetPublishMenuOpen
|
||||
toggleInputPanel: typeof mockToggleInputPanel
|
||||
togglePublishMenu: typeof mockTogglePublishMenu
|
||||
}) => unknown) => selector({
|
||||
editingField: null,
|
||||
isEditorOpen: false,
|
||||
isInputPanelOpen: true,
|
||||
isPublishMenuOpen: false,
|
||||
closeEditor: mockCloseEditor,
|
||||
openEditor: mockOpenEditor,
|
||||
reset: mockReset,
|
||||
setInputPanelOpen: mockSetInputPanelOpen,
|
||||
setPublishMenuOpen: mockSetPublishMenuOpen,
|
||||
toggleInputPanel: mockToggleInputPanel,
|
||||
togglePublishMenu: mockTogglePublishMenu,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippet-workflows', () => ({
|
||||
usePublishSnippetWorkflowMutation: () => ({
|
||||
mutateAsync: mockPublishSnippetMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/hooks/use-configs-map', () => ({
|
||||
useConfigsMap: () => ({
|
||||
flowId: 'snippet-1',
|
||||
flowType: 'snippet',
|
||||
fileSettings: {},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => ({
|
||||
useSetWorkflowVarsWithValue: () => ({
|
||||
fetchInspectVars: mockFetchInspectVars,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/hooks/use-inspect-vars-crud', () => ({
|
||||
useInspectVarsCrud: () => mockInspectVarsCrud,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/hooks/use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
doSyncWorkflowDraft: vi.fn(),
|
||||
syncInputFieldsDraft: mockSyncInputFieldsDraft,
|
||||
syncWorkflowDraftWhenPageClose: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/hooks/use-snippet-refresh-draft', () => ({
|
||||
useSnippetRefreshDraft: () => ({
|
||||
handleRefreshWorkflowDraft: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/hooks/use-snippet-run', () => ({
|
||||
useSnippetRun: () => ({
|
||||
handleBackupDraft: mockHandleBackupDraft,
|
||||
handleLoadBackupDraft: mockHandleLoadBackupDraft,
|
||||
handleRestoreFromPublishedWorkflow: mockHandleRestoreFromPublishedWorkflow,
|
||||
handleRun: mockHandleRun,
|
||||
handleStopRun: mockHandleStopRun,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/hooks/use-snippet-start-run', () => ({
|
||||
useSnippetStartRun: () => ({
|
||||
handleStartWorkflowRun: mockHandleStartWorkflowRun,
|
||||
handleWorkflowStartRunInWorkflow: mockHandleWorkflowStartRunInWorkflow,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow', () => ({
|
||||
WorkflowWithInnerContext: ({
|
||||
children,
|
||||
hooksStore,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
hooksStore?: Record<string, unknown>
|
||||
}) => {
|
||||
capturedHooksStore = hooksStore
|
||||
|
||||
return (
|
||||
<div data-testid="workflow-inner-context">{children}</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/components/snippet-children', () => ({
|
||||
default: ({
|
||||
onRemoveField,
|
||||
onPublish,
|
||||
onSubmitField,
|
||||
}: {
|
||||
onRemoveField: (index: number) => void
|
||||
onPublish: () => void
|
||||
onSubmitField: (field: SnippetInputField) => void
|
||||
}) => (
|
||||
<div>
|
||||
<button type="button" onClick={() => onRemoveField(0)}>remove</button>
|
||||
<button type="button" onClick={onPublish}>publish</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSubmitField({
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'New Field',
|
||||
variable: 'new_field',
|
||||
required: true,
|
||||
})}
|
||||
>
|
||||
submit
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const payload: SnippetDetailPayload = {
|
||||
snippet: {
|
||||
id: 'snippet-1',
|
||||
name: 'Snippet',
|
||||
description: 'desc',
|
||||
author: '',
|
||||
updatedAt: '2026-03-29 10:00',
|
||||
usage: '0',
|
||||
icon: '',
|
||||
iconBackground: '',
|
||||
},
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
inputFields: [
|
||||
{
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'Blog URL',
|
||||
variable: 'blog_url',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
uiMeta: {
|
||||
inputFieldCount: 1,
|
||||
checklistCount: 0,
|
||||
autoSavedAt: '2026-03-29 10:00',
|
||||
},
|
||||
}
|
||||
|
||||
const renderSnippetMain = () => {
|
||||
return render(
|
||||
<SnippetMain
|
||||
payload={payload}
|
||||
snippetId="snippet-1"
|
||||
nodes={[] as WorkflowProps['nodes']}
|
||||
edges={[] as WorkflowProps['edges']}
|
||||
viewport={{ x: 0, y: 0, zoom: 1 }}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('SnippetMain', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSyncInputFieldsDraft.mockResolvedValue(undefined)
|
||||
mockPublishSnippetMutateAsync.mockResolvedValue(undefined)
|
||||
capturedHooksStore = undefined
|
||||
})
|
||||
|
||||
describe('Input Fields Sync', () => {
|
||||
it('should sync draft input_fields when removing a field from the panel', async () => {
|
||||
renderSnippetMain()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'remove' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([], {
|
||||
onRefresh: expect.any(Function),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should sync draft input_fields when submitting a field from the editor', async () => {
|
||||
renderSnippetMain()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'submit' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([
|
||||
payload.inputFields[0],
|
||||
{
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'New Field',
|
||||
variable: 'new_field',
|
||||
required: true,
|
||||
},
|
||||
], {
|
||||
onRefresh: expect.any(Function),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Publish', () => {
|
||||
it('should call the publish mutation and close the publish menu', async () => {
|
||||
renderSnippetMain()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'publish' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPublishSnippetMutateAsync).toHaveBeenCalledWith({
|
||||
params: { snippetId: 'snippet-1' },
|
||||
})
|
||||
})
|
||||
expect(mockSetPublishMenuOpen).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Inspect Vars', () => {
|
||||
it('should pass inspect vars handlers to WorkflowWithInnerContext', () => {
|
||||
renderSnippetMain()
|
||||
|
||||
expect(capturedHooksStore?.fetchInspectVars).toBe(mockFetchInspectVars)
|
||||
expect(capturedHooksStore?.hasNodeInspectVars).toBe(mockInspectVarsCrud.hasNodeInspectVars)
|
||||
expect(capturedHooksStore?.hasSetInspectVar).toBe(mockInspectVarsCrud.hasSetInspectVar)
|
||||
expect(capturedHooksStore?.fetchInspectVarValue).toBe(mockInspectVarsCrud.fetchInspectVarValue)
|
||||
expect(capturedHooksStore?.editInspectVarValue).toBe(mockInspectVarsCrud.editInspectVarValue)
|
||||
expect(capturedHooksStore?.renameInspectVarName).toBe(mockInspectVarsCrud.renameInspectVarName)
|
||||
expect(capturedHooksStore?.appendNodeInspectVars).toBe(mockInspectVarsCrud.appendNodeInspectVars)
|
||||
expect(capturedHooksStore?.deleteInspectVar).toBe(mockInspectVarsCrud.deleteInspectVar)
|
||||
expect(capturedHooksStore?.deleteNodeInspectorVars).toBe(mockInspectVarsCrud.deleteNodeInspectorVars)
|
||||
expect(capturedHooksStore?.deleteAllInspectorVars).toBe(mockInspectVarsCrud.deleteAllInspectorVars)
|
||||
expect(capturedHooksStore?.isInspectVarEdited).toBe(mockInspectVarsCrud.isInspectVarEdited)
|
||||
expect(capturedHooksStore?.resetToLastRunVar).toBe(mockInspectVarsCrud.resetToLastRunVar)
|
||||
expect(capturedHooksStore?.invalidateSysVarValues).toBe(mockInspectVarsCrud.invalidateSysVarValues)
|
||||
expect(capturedHooksStore?.resetConversationVar).toBe(mockInspectVarsCrud.resetConversationVar)
|
||||
expect(capturedHooksStore?.invalidateConversationVarValues).toBe(mockInspectVarsCrud.invalidateConversationVarValues)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Run Hooks', () => {
|
||||
it('should pass snippet run handlers to WorkflowWithInnerContext', () => {
|
||||
renderSnippetMain()
|
||||
|
||||
expect(capturedHooksStore?.handleBackupDraft).toBe(mockHandleBackupDraft)
|
||||
expect(capturedHooksStore?.handleLoadBackupDraft).toBe(mockHandleLoadBackupDraft)
|
||||
expect(capturedHooksStore?.handleRestoreFromPublishedWorkflow).toBe(mockHandleRestoreFromPublishedWorkflow)
|
||||
expect(capturedHooksStore?.handleRun).toBe(mockHandleRun)
|
||||
expect(capturedHooksStore?.handleStopRun).toBe(mockHandleStopRun)
|
||||
expect(capturedHooksStore?.handleStartWorkflowRun).toBe(mockHandleStartWorkflowRun)
|
||||
expect(capturedHooksStore?.handleWorkflowStartRunInWorkflow).toBe(mockHandleWorkflowStartRunInWorkflow)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { PanelProps } from '@/app/components/workflow/panel'
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import SnippetWorkflowPanel from '../workflow-panel'
|
||||
|
||||
let capturedPanelProps: PanelProps | null = null
|
||||
|
||||
vi.mock('@/app/components/workflow/panel', () => ({
|
||||
default: (props: PanelProps) => {
|
||||
capturedPanelProps = props
|
||||
return <div data-testid="workflow-panel">{props.components?.left}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
const defaultFields: SnippetInputField[] = []
|
||||
|
||||
describe('SnippetWorkflowPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
capturedPanelProps = null
|
||||
})
|
||||
|
||||
// Verifies snippet panel wires version history support into the shared workflow panel.
|
||||
describe('Rendering', () => {
|
||||
it('should pass snippet version history panel props to the shared workflow panel', async () => {
|
||||
render(
|
||||
<SnippetWorkflowPanel
|
||||
snippetId="snippet-1"
|
||||
fields={defaultFields}
|
||||
editingField={null}
|
||||
isEditorOpen={false}
|
||||
isInputPanelOpen={false}
|
||||
onCloseInputPanel={vi.fn()}
|
||||
onOpenEditor={vi.fn()}
|
||||
onCloseEditor={vi.fn()}
|
||||
onSubmitField={vi.fn()}
|
||||
onRemoveField={vi.fn()}
|
||||
onSortChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedPanelProps?.versionHistoryPanelProps?.getVersionListUrl).toBe('/snippets/snippet-1/workflows')
|
||||
expect(capturedPanelProps?.versionHistoryPanelProps?.deleteVersionUrl?.('version-1')).toBe('/snippets/snippet-1/workflows/version-1')
|
||||
expect(capturedPanelProps?.versionHistoryPanelProps?.restoreVersionUrl('version-1')).toBe('/snippets/snippet-1/workflows/version-1/restore')
|
||||
expect(capturedPanelProps?.versionHistoryPanelProps?.updateVersionUrl?.('version-1')).toBe('/snippets/snippet-1/workflows/version-1')
|
||||
expect(capturedPanelProps?.versionHistoryPanelProps?.latestVersionId).toBe('')
|
||||
expect(capturedPanelProps?.components?.right).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import { useSnippetInputFieldActions } from '../use-snippet-input-field-actions'
|
||||
|
||||
const mockSyncInputFieldsDraft = vi.fn()
|
||||
const mockCloseEditor = vi.fn()
|
||||
const mockOpenEditor = vi.fn()
|
||||
const mockSetInputPanelOpen = vi.fn()
|
||||
const mockToggleInputPanel = vi.fn()
|
||||
|
||||
let snippetDetailStoreState: {
|
||||
editingField: SnippetInputField | null
|
||||
isEditorOpen: boolean
|
||||
isInputPanelOpen: boolean
|
||||
closeEditor: typeof mockCloseEditor
|
||||
openEditor: typeof mockOpenEditor
|
||||
setInputPanelOpen: typeof mockSetInputPanelOpen
|
||||
toggleInputPanel: typeof mockToggleInputPanel
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../../hooks/use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
syncInputFieldsDraft: mockSyncInputFieldsDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../store', () => ({
|
||||
useSnippetDetailStore: (selector: (state: typeof snippetDetailStoreState) => unknown) => selector(snippetDetailStoreState),
|
||||
}))
|
||||
|
||||
const createField = (overrides: Partial<SnippetInputField> = {}): SnippetInputField => ({
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'Blog URL',
|
||||
variable: 'blog_url',
|
||||
required: true,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('useSnippetInputFieldActions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
snippetDetailStoreState = {
|
||||
editingField: null,
|
||||
isEditorOpen: false,
|
||||
isInputPanelOpen: true,
|
||||
closeEditor: mockCloseEditor,
|
||||
openEditor: mockOpenEditor,
|
||||
setInputPanelOpen: mockSetInputPanelOpen,
|
||||
toggleInputPanel: mockToggleInputPanel,
|
||||
}
|
||||
mockSyncInputFieldsDraft.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('Field sync', () => {
|
||||
it('should remove a field and sync the draft', () => {
|
||||
const { result } = renderHook(() => useSnippetInputFieldActions({
|
||||
snippetId: 'snippet-1',
|
||||
initialFields: [createField()],
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleRemoveField(0)
|
||||
})
|
||||
|
||||
expect(result.current.fields).toEqual([])
|
||||
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([], {
|
||||
onRefresh: expect.any(Function),
|
||||
})
|
||||
})
|
||||
|
||||
it('should append a new field and close the editor after syncing', () => {
|
||||
const { result } = renderHook(() => useSnippetInputFieldActions({
|
||||
snippetId: 'snippet-1',
|
||||
initialFields: [createField()],
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSubmitField(createField({
|
||||
label: 'Topic',
|
||||
variable: 'topic',
|
||||
}))
|
||||
})
|
||||
|
||||
expect(result.current.fields).toEqual([
|
||||
createField(),
|
||||
createField({
|
||||
label: 'Topic',
|
||||
variable: 'topic',
|
||||
}),
|
||||
])
|
||||
expect(mockSyncInputFieldsDraft).toHaveBeenCalledWith([
|
||||
createField(),
|
||||
createField({
|
||||
label: 'Topic',
|
||||
variable: 'topic',
|
||||
}),
|
||||
], {
|
||||
onRefresh: expect.any(Function),
|
||||
})
|
||||
expect(mockCloseEditor).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should reject duplicated variables without syncing', () => {
|
||||
const { result } = renderHook(() => useSnippetInputFieldActions({
|
||||
snippetId: 'snippet-1',
|
||||
initialFields: [createField()],
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSubmitField(createField({
|
||||
label: 'Duplicated',
|
||||
variable: 'blog_url',
|
||||
}))
|
||||
})
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('datasetPipeline.inputFieldPanel.error.variableDuplicate')
|
||||
expect(mockSyncInputFieldsDraft).not.toHaveBeenCalled()
|
||||
expect(mockCloseEditor).not.toHaveBeenCalled()
|
||||
expect(result.current.fields).toEqual([createField()])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Panel actions', () => {
|
||||
it('should close the editor before toggling the input panel when the panel is open', () => {
|
||||
const { result } = renderHook(() => useSnippetInputFieldActions({
|
||||
snippetId: 'snippet-1',
|
||||
initialFields: [createField()],
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleToggleInputPanel()
|
||||
})
|
||||
|
||||
expect(mockCloseEditor).toHaveBeenCalledTimes(1)
|
||||
expect(mockToggleInputPanel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should close the input panel and clear the editor state', () => {
|
||||
const { result } = renderHook(() => useSnippetInputFieldActions({
|
||||
snippetId: 'snippet-1',
|
||||
initialFields: [createField()],
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleCloseInputPanel()
|
||||
})
|
||||
|
||||
expect(mockCloseEditor).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetInputPanelOpen).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { useSnippetPublish } from '../use-snippet-publish'
|
||||
|
||||
const mockMutateAsync = vi.fn()
|
||||
const mockSetPublishMenuOpen = vi.fn()
|
||||
const mockUseKeyPress = vi.fn()
|
||||
|
||||
let isPublishMenuOpen = false
|
||||
let isPending = false
|
||||
let shortcutHandler: ((event: KeyboardEvent) => void) | undefined
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useKeyPress: (...args: Parameters<typeof mockUseKeyPress>) => mockUseKeyPress(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippet-workflows', () => ({
|
||||
usePublishSnippetWorkflowMutation: () => ({
|
||||
mutateAsync: mockMutateAsync,
|
||||
isPending,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../store', () => ({
|
||||
useSnippetDetailStore: (selector: (state: {
|
||||
isPublishMenuOpen: boolean
|
||||
setPublishMenuOpen: typeof mockSetPublishMenuOpen
|
||||
}) => unknown) => selector({
|
||||
isPublishMenuOpen,
|
||||
setPublishMenuOpen: mockSetPublishMenuOpen,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('useSnippetPublish', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isPublishMenuOpen = false
|
||||
isPending = false
|
||||
shortcutHandler = undefined
|
||||
mockMutateAsync.mockResolvedValue(undefined)
|
||||
mockUseKeyPress.mockImplementation((_key, handler) => {
|
||||
shortcutHandler = handler
|
||||
})
|
||||
})
|
||||
|
||||
describe('Publish action', () => {
|
||||
it('should publish the snippet, close the menu, and show success feedback', async () => {
|
||||
const { result } = renderHook(() => useSnippetPublish({
|
||||
snippetId: 'snippet-1',
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handlePublish()
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
params: { snippetId: 'snippet-1' },
|
||||
})
|
||||
expect(mockSetPublishMenuOpen).toHaveBeenCalledWith(false)
|
||||
expect(toast.success).toHaveBeenCalledWith('snippet.publishSuccess')
|
||||
})
|
||||
|
||||
it('should surface publish errors through toast feedback', async () => {
|
||||
mockMutateAsync.mockRejectedValue(new Error('publish failed'))
|
||||
|
||||
const { result } = renderHook(() => useSnippetPublish({
|
||||
snippetId: 'snippet-1',
|
||||
}))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handlePublish()
|
||||
})
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('publish failed')
|
||||
expect(mockSetPublishMenuOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Keyboard shortcut', () => {
|
||||
it('should trigger publish on ctrl+shift+p in the orchestrate section', async () => {
|
||||
renderHook(() => useSnippetPublish({
|
||||
snippetId: 'snippet-1',
|
||||
}))
|
||||
|
||||
const event = new KeyboardEvent('keydown')
|
||||
const preventDefault = vi.spyOn(event, 'preventDefault')
|
||||
|
||||
act(() => {
|
||||
shortcutHandler?.(event)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
params: { snippetId: 'snippet-1' },
|
||||
})
|
||||
})
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should ignore the shortcut outside the orchestrate section', () => {
|
||||
renderHook(() => useSnippetPublish({
|
||||
snippetId: 'snippet-1',
|
||||
}))
|
||||
|
||||
const event = new KeyboardEvent('keydown')
|
||||
const preventDefault = vi.spyOn(event, 'preventDefault')
|
||||
|
||||
act(() => {
|
||||
shortcutHandler?.(event)
|
||||
})
|
||||
|
||||
expect(mockMutateAsync).not.toHaveBeenCalled()
|
||||
expect(preventDefault).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { useNodesSyncDraft } from '../../hooks/use-nodes-sync-draft'
|
||||
import { useSnippetDetailStore } from '../../store'
|
||||
|
||||
type UseSnippetInputFieldActionsOptions = {
|
||||
snippetId: string
|
||||
initialFields: SnippetInputField[]
|
||||
}
|
||||
|
||||
export const useSnippetInputFieldActions = ({
|
||||
snippetId,
|
||||
initialFields,
|
||||
}: UseSnippetInputFieldActionsOptions) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const [fields, setFields] = useState<SnippetInputField[]>(initialFields)
|
||||
const { syncInputFieldsDraft } = useNodesSyncDraft(snippetId)
|
||||
const {
|
||||
editingField,
|
||||
isEditorOpen,
|
||||
isInputPanelOpen,
|
||||
closeEditor,
|
||||
openEditor,
|
||||
setInputPanelOpen,
|
||||
toggleInputPanel,
|
||||
} = useSnippetDetailStore(useShallow(state => ({
|
||||
editingField: state.editingField,
|
||||
isEditorOpen: state.isEditorOpen,
|
||||
isInputPanelOpen: state.isInputPanelOpen,
|
||||
closeEditor: state.closeEditor,
|
||||
openEditor: state.openEditor,
|
||||
setInputPanelOpen: state.setInputPanelOpen,
|
||||
toggleInputPanel: state.toggleInputPanel,
|
||||
})))
|
||||
|
||||
const handleSortChange = useCallback((newFields: SnippetInputField[]) => {
|
||||
setFields(newFields)
|
||||
}, [])
|
||||
|
||||
const handleRemoveField = useCallback((index: number) => {
|
||||
const nextFields = fields.filter((_, currentIndex) => currentIndex !== index)
|
||||
setFields(nextFields)
|
||||
void syncInputFieldsDraft(nextFields, {
|
||||
onRefresh: setFields,
|
||||
})
|
||||
}, [fields, syncInputFieldsDraft])
|
||||
|
||||
const handleSubmitField = useCallback((field: SnippetInputField) => {
|
||||
const originalVariable = editingField?.variable
|
||||
const duplicated = fields.some(item => item.variable === field.variable && item.variable !== originalVariable)
|
||||
|
||||
if (duplicated) {
|
||||
toast.error(t('inputFieldPanel.error.variableDuplicate', { ns: 'datasetPipeline' }))
|
||||
return
|
||||
}
|
||||
|
||||
const nextFields = originalVariable
|
||||
? fields.map(item => item.variable === originalVariable ? field : item)
|
||||
: [...fields, field]
|
||||
|
||||
setFields(nextFields)
|
||||
void syncInputFieldsDraft(nextFields, {
|
||||
onRefresh: setFields,
|
||||
})
|
||||
closeEditor()
|
||||
}, [closeEditor, editingField?.variable, fields, syncInputFieldsDraft, t])
|
||||
|
||||
const handleToggleInputPanel = useCallback(() => {
|
||||
if (isInputPanelOpen)
|
||||
closeEditor()
|
||||
toggleInputPanel()
|
||||
}, [closeEditor, isInputPanelOpen, toggleInputPanel])
|
||||
|
||||
const handleCloseInputPanel = useCallback(() => {
|
||||
closeEditor()
|
||||
setInputPanelOpen(false)
|
||||
}, [closeEditor, setInputPanelOpen])
|
||||
|
||||
return {
|
||||
editingField,
|
||||
fields,
|
||||
isEditorOpen,
|
||||
isInputPanelOpen,
|
||||
openEditor,
|
||||
closeEditor,
|
||||
handleCloseInputPanel,
|
||||
handleRemoveField,
|
||||
handleSortChange,
|
||||
handleSubmitField,
|
||||
handleToggleInputPanel,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils'
|
||||
import { usePublishSnippetWorkflowMutation } from '@/service/use-snippet-workflows'
|
||||
import { useSnippetDetailStore } from '../../store'
|
||||
|
||||
type UseSnippetPublishOptions = {
|
||||
snippetId: string
|
||||
}
|
||||
|
||||
export const useSnippetPublish = ({
|
||||
snippetId,
|
||||
}: UseSnippetPublishOptions) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const publishSnippetMutation = usePublishSnippetWorkflowMutation(snippetId)
|
||||
const {
|
||||
isPublishMenuOpen,
|
||||
setPublishMenuOpen,
|
||||
} = useSnippetDetailStore(useShallow(state => ({
|
||||
isPublishMenuOpen: state.isPublishMenuOpen,
|
||||
setPublishMenuOpen: state.setPublishMenuOpen,
|
||||
})))
|
||||
|
||||
const handlePublish = useCallback(async () => {
|
||||
try {
|
||||
await publishSnippetMutation.mutateAsync({
|
||||
params: { snippetId },
|
||||
})
|
||||
setPublishMenuOpen(false)
|
||||
toast.success(t('publishSuccess'))
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('publishFailed'))
|
||||
}
|
||||
}, [publishSnippetMutation, setPublishMenuOpen, snippetId, t])
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (event) => {
|
||||
if (publishSnippetMutation.isPending)
|
||||
return
|
||||
|
||||
event.preventDefault()
|
||||
void handlePublish()
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
return {
|
||||
handlePublish,
|
||||
isPublishMenuOpen,
|
||||
isPublishing: publishSnippetMutation.isPending,
|
||||
setPublishMenuOpen,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client'
|
||||
|
||||
import type { FormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/types'
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import InputFieldForm from '@/app/components/rag-pipeline/components/panel/input-field/editor/form'
|
||||
import { convertFormDataToINputField, convertToInputFieldFormData } from '@/app/components/rag-pipeline/components/panel/input-field/editor/utils'
|
||||
import { useFloatingRight } from '@/app/components/rag-pipeline/components/panel/input-field/hooks'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type SnippetInputFieldEditorProps = {
|
||||
field?: SnippetInputField | null
|
||||
onClose: () => void
|
||||
onSubmit: (field: SnippetInputField) => void
|
||||
}
|
||||
|
||||
const SnippetInputFieldEditor = ({
|
||||
field,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: SnippetInputFieldEditorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { floatingRight, floatingRightWidth } = useFloatingRight(400)
|
||||
|
||||
const initialData = useMemo(() => {
|
||||
return convertToInputFieldFormData(field || undefined)
|
||||
}, [field])
|
||||
|
||||
const handleSubmit = useCallback((value: FormData) => {
|
||||
onSubmit(convertFormDataToINputField(value))
|
||||
}, [onSubmit])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative mr-1 flex h-fit max-h-full flex-col overflow-y-auto rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9',
|
||||
'transition-all duration-300 ease-in-out',
|
||||
floatingRight && 'absolute right-0 z-[100]',
|
||||
)}
|
||||
style={{
|
||||
width: `min(${floatingRightWidth}px, calc(100vw - 24px))`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center pb-1 pl-4 pr-11 pt-3.5 text-text-primary system-xl-semibold">
|
||||
{field ? t('inputFieldPanel.editInputField', { ns: 'datasetPipeline' }) : t('inputFieldPanel.addInputField', { ns: 'datasetPipeline' })}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2.5 top-2.5 flex h-8 w-8 items-center justify-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
|
||||
</button>
|
||||
<InputFieldForm
|
||||
initialData={initialData}
|
||||
supportFile
|
||||
onCancel={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
isEditMode={!!field}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetInputFieldEditor
|
||||
86
web/app/components/snippets/components/panel/index.tsx
Normal file
86
web/app/components/snippets/components/panel/index.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
|
||||
import type { SortableItem } from '@/app/components/rag-pipeline/components/panel/input-field/field-list/types'
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import FieldListContainer from '@/app/components/rag-pipeline/components/panel/input-field/field-list/field-list-container'
|
||||
|
||||
type SnippetInputFieldPanelProps = {
|
||||
fields: SnippetInputField[]
|
||||
onClose: () => void
|
||||
onAdd: () => void
|
||||
onEdit: (field: SnippetInputField) => void
|
||||
onRemove: (index: number) => void
|
||||
onSortChange: (fields: SnippetInputField[]) => void
|
||||
}
|
||||
|
||||
const toInputFields = (list: SortableItem[]) => {
|
||||
return list.map((item) => {
|
||||
const { id: _id, chosen: _chosen, selected: _selected, ...field } = item
|
||||
return field
|
||||
})
|
||||
}
|
||||
|
||||
const SnippetInputFieldPanel = ({
|
||||
fields,
|
||||
onClose,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onRemove,
|
||||
onSortChange,
|
||||
}: SnippetInputFieldPanelProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
|
||||
const handleRemove = useCallback((index: number) => {
|
||||
onRemove(index)
|
||||
}, [onRemove])
|
||||
|
||||
const handleEdit = useCallback((id: string) => {
|
||||
const field = fields.find(item => item.variable === id)
|
||||
if (field)
|
||||
onEdit(field)
|
||||
}, [fields, onEdit])
|
||||
|
||||
return (
|
||||
<div className="mr-1 flex h-full w-[min(400px,calc(100vw-24px))] flex-col rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5">
|
||||
<div className="flex items-start justify-between gap-3 px-4 pb-2 pt-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-text-primary system-xl-semibold">
|
||||
{t('panelTitle')}
|
||||
</div>
|
||||
<div className="pt-1 text-text-tertiary system-sm-regular">
|
||||
{t('panelDescription')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover"
|
||||
onClick={onClose}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-line h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-2">
|
||||
<Button variant="primary" size="medium" className="gap-0.5 px-3" onClick={onAdd}>
|
||||
<span aria-hidden className="i-ri-add-line h-4 w-4" />
|
||||
{t('inputFieldPanel.addInputField', { ns: 'datasetPipeline' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex grow flex-col overflow-y-auto">
|
||||
<FieldListContainer
|
||||
className="flex flex-col gap-y-1 px-4 py-4"
|
||||
inputFields={fields}
|
||||
onListSortChange={list => onSortChange(toInputFields(list))}
|
||||
onRemoveField={handleRemove}
|
||||
onEditField={handleEdit}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(SnippetInputFieldPanel)
|
||||
48
web/app/components/snippets/components/publish-menu.tsx
Normal file
48
web/app/components/snippets/components/publish-menu.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetDetailUIModel } from '@/models/snippet'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
|
||||
|
||||
const PublishMenu = ({
|
||||
uiMeta,
|
||||
onPublish,
|
||||
isPublishing = false,
|
||||
}: {
|
||||
uiMeta: SnippetDetailUIModel
|
||||
onPublish: () => void
|
||||
isPublishing?: boolean
|
||||
}) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-4 pb-4 pt-3">
|
||||
<div className="flex flex-col">
|
||||
<div className="min-h-6 text-text-tertiary system-xs-medium-uppercase">
|
||||
{t('publishMenuCurrentDraft')}
|
||||
</div>
|
||||
<div className="text-text-secondary system-sm-medium">
|
||||
{uiMeta.autoSavedAt}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={isPublishing}
|
||||
disabled={isPublishing}
|
||||
className="w-full justify-center gap-1.5"
|
||||
onClick={onPublish}
|
||||
>
|
||||
<span>{t('publishButton')}</span>
|
||||
<div aria-hidden="true">
|
||||
<ShortcutsName
|
||||
keys={['ctrl', 'shift', 'p']}
|
||||
bgColor="white"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PublishMenu
|
||||
58
web/app/components/snippets/components/snippet-card.tsx
Normal file
58
web/app/components/snippets/components/snippet-card.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetListItem } from '@/types/snippet'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Link from '@/next/link'
|
||||
|
||||
type Props = {
|
||||
snippet: SnippetListItem
|
||||
}
|
||||
|
||||
const SnippetCard = ({ snippet }: Props) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
|
||||
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>
|
||||
)}
|
||||
<div className="flex h-[66px] items-center gap-3 px-[14px] pb-3 pt-[14px]">
|
||||
<AppIcon
|
||||
size="large"
|
||||
iconType={snippet.icon_info.icon_type}
|
||||
icon={snippet.icon_info.icon}
|
||||
background={snippet.icon_info.icon_background}
|
||||
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}>
|
||||
{snippet.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[58px] px-[14px] text-xs leading-normal text-text-tertiary">
|
||||
<div className="line-clamp-2" title={snippet.description}>
|
||||
{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 && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="truncate">{t('usageCount', { count: snippet.use_count })}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetCard
|
||||
103
web/app/components/snippets/components/snippet-children.tsx
Normal file
103
web/app/components/snippets/components/snippet-children.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetDetailUIModel, SnippetInputField } from '@/models/snippet'
|
||||
import SnippetInputFieldEditor from './input-field-editor'
|
||||
import SnippetInputFieldPanel from './panel'
|
||||
import SnippetHeader from './snippet-header'
|
||||
import SnippetWorkflowPanel from './workflow-panel'
|
||||
|
||||
type SnippetChildrenProps = {
|
||||
snippetId: string
|
||||
fields: SnippetInputField[]
|
||||
uiMeta: SnippetDetailUIModel
|
||||
editingField: SnippetInputField | null
|
||||
isEditorOpen: boolean
|
||||
isInputPanelOpen: boolean
|
||||
isPublishMenuOpen: boolean
|
||||
isPublishing: boolean
|
||||
onToggleInputPanel: () => void
|
||||
onPublishMenuOpenChange: (open: boolean) => void
|
||||
onCloseInputPanel: () => void
|
||||
onPublish: () => void
|
||||
onOpenEditor: (field?: SnippetInputField | null) => void
|
||||
onCloseEditor: () => void
|
||||
onSubmitField: (field: SnippetInputField) => void
|
||||
onRemoveField: (index: number) => void
|
||||
onSortChange: (fields: SnippetInputField[]) => void
|
||||
}
|
||||
|
||||
const SnippetChildren = ({
|
||||
snippetId,
|
||||
fields,
|
||||
uiMeta,
|
||||
editingField,
|
||||
isEditorOpen,
|
||||
isInputPanelOpen,
|
||||
isPublishMenuOpen,
|
||||
isPublishing,
|
||||
onToggleInputPanel,
|
||||
onPublishMenuOpenChange,
|
||||
onCloseInputPanel,
|
||||
onPublish,
|
||||
onOpenEditor,
|
||||
onCloseEditor,
|
||||
onSubmitField,
|
||||
onRemoveField,
|
||||
onSortChange,
|
||||
}: SnippetChildrenProps) => {
|
||||
return (
|
||||
<>
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-24 bg-gradient-to-b from-background-body to-transparent" />
|
||||
|
||||
<SnippetHeader
|
||||
snippetId={snippetId}
|
||||
inputFieldCount={fields.length}
|
||||
uiMeta={uiMeta}
|
||||
isPublishMenuOpen={isPublishMenuOpen}
|
||||
isPublishing={isPublishing}
|
||||
onToggleInputPanel={onToggleInputPanel}
|
||||
onPublishMenuOpenChange={onPublishMenuOpenChange}
|
||||
onPublish={onPublish}
|
||||
/>
|
||||
|
||||
<SnippetWorkflowPanel
|
||||
snippetId={snippetId}
|
||||
fields={fields}
|
||||
editingField={editingField}
|
||||
isEditorOpen={isEditorOpen}
|
||||
isInputPanelOpen={isInputPanelOpen}
|
||||
onCloseInputPanel={onCloseInputPanel}
|
||||
onOpenEditor={onOpenEditor}
|
||||
onCloseEditor={onCloseEditor}
|
||||
onSubmitField={onSubmitField}
|
||||
onRemoveField={onRemoveField}
|
||||
onSortChange={onSortChange}
|
||||
/>
|
||||
|
||||
{(isInputPanelOpen || isEditorOpen) && (
|
||||
<div className="pointer-events-none absolute bottom-1 right-1 top-14 z-30 flex justify-end">
|
||||
<div className="pointer-events-auto flex h-full xl:hidden">
|
||||
{isEditorOpen && (
|
||||
<SnippetInputFieldEditor
|
||||
field={editingField}
|
||||
onClose={onCloseEditor}
|
||||
onSubmit={onSubmitField}
|
||||
/>
|
||||
)}
|
||||
<SnippetInputFieldPanel
|
||||
fields={fields}
|
||||
onClose={onCloseInputPanel}
|
||||
onAdd={() => onOpenEditor()}
|
||||
onEdit={onOpenEditor}
|
||||
onRemove={onRemoveField}
|
||||
onSortChange={onSortChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetChildren
|
||||
109
web/app/components/snippets/components/snippet-create-card.tsx
Normal file
109
web/app/components/snippets/components/snippet-create-card.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
'use client'
|
||||
|
||||
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import CreateSnippetDialog from '@/app/components/workflow/create-snippet-dialog'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import {
|
||||
useCreateSnippetMutation,
|
||||
} from '@/service/use-snippets'
|
||||
import SnippetImportDSLDialog from './snippet-import-dsl-dialog'
|
||||
|
||||
const SnippetCreateCard = () => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const { push } = useRouter()
|
||||
const createSnippetMutation = useCreateSnippetMutation()
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
const [isImportDSLDialogOpen, setIsImportDSLDialogOpen] = useState(false)
|
||||
|
||||
const handleCreateFromBlank = () => {
|
||||
setIsCreateDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleImportDSL = () => {
|
||||
setIsImportDSLDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleCreateSnippet = ({
|
||||
name,
|
||||
description,
|
||||
icon,
|
||||
}: {
|
||||
name: string
|
||||
description: string
|
||||
icon: AppIconSelection
|
||||
}) => {
|
||||
createSnippetMutation.mutate({
|
||||
body: {
|
||||
name,
|
||||
description: description || undefined,
|
||||
icon_info: {
|
||||
icon: icon.type === 'emoji' ? icon.icon : icon.fileId,
|
||||
icon_type: icon.type,
|
||||
icon_background: icon.type === 'emoji' ? icon.background : undefined,
|
||||
icon_url: icon.type === 'image' ? icon.url : undefined,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
onSuccess: (snippet) => {
|
||||
toast.success(t('snippet.createSuccess', { ns: 'workflow' }))
|
||||
setIsCreateDialogOpen(false)
|
||||
push(`/snippets/${snippet.id}/orchestrate`)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : t('createFailed'))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative col-span-1 inline-flex h-[160px] flex-col justify-between rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg transition-opacity">
|
||||
<div className="grow rounded-t-xl p-2">
|
||||
<div className="px-6 pb-1 pt-2 text-xs font-medium leading-[18px] text-text-tertiary">{t('create')}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mb-1 flex w-full cursor-pointer items-center rounded-lg px-6 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={createSnippetMutation.isPending}
|
||||
onClick={handleCreateFromBlank}
|
||||
>
|
||||
<span aria-hidden className="i-ri-sticky-note-add-line mr-2 h-4 w-4 shrink-0" />
|
||||
{t('createFromBlank')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer items-center rounded-lg px-6 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
onClick={handleImportDSL}
|
||||
>
|
||||
<span aria-hidden className="i-ri-file-upload-line mr-2 h-4 w-4 shrink-0" />
|
||||
{t('importDSL', { ns: 'app' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isCreateDialogOpen && (
|
||||
<CreateSnippetDialog
|
||||
isOpen={isCreateDialogOpen}
|
||||
isSubmitting={createSnippetMutation.isPending}
|
||||
onClose={() => setIsCreateDialogOpen(false)}
|
||||
onConfirm={handleCreateSnippet}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isImportDSLDialogOpen && (
|
||||
<SnippetImportDSLDialog
|
||||
show={isImportDSLDialogOpen}
|
||||
onClose={() => setIsImportDSLDialogOpen(false)}
|
||||
onSuccess={(snippetId) => {
|
||||
setIsImportDSLDialogOpen(false)
|
||||
push(`/snippets/${snippetId}/orchestrate`)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetCreateCard
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { HeaderProps } from '@/app/components/workflow/header'
|
||||
import type { SnippetDetailUIModel } from '@/models/snippet'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import SnippetHeader from '..'
|
||||
|
||||
vi.mock('@/app/components/workflow/header', () => ({
|
||||
default: (props: HeaderProps) => {
|
||||
const CustomRunMode = props.normal?.runAndHistoryProps?.components?.RunMode
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="workflow-header"
|
||||
data-show-env={String(props.normal?.controls?.showEnvButton ?? true)}
|
||||
data-show-global-variable={String(props.normal?.controls?.showGlobalVariableButton ?? true)}
|
||||
data-history-url={props.normal?.runAndHistoryProps?.viewHistoryProps?.historyUrl ?? ''}
|
||||
>
|
||||
{props.normal?.components?.left}
|
||||
{CustomRunMode && <CustomRunMode text={props.normal?.runAndHistoryProps?.runButtonText} />}
|
||||
{props.normal?.components?.middle}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
describe('SnippetHeader', () => {
|
||||
const mockToggleInputPanel = vi.fn()
|
||||
const mockPublishMenuOpenChange = vi.fn()
|
||||
const mockPublish = vi.fn()
|
||||
const uiMeta: SnippetDetailUIModel = {
|
||||
inputFieldCount: 1,
|
||||
checklistCount: 2,
|
||||
autoSavedAt: 'Auto-saved · a few seconds ago',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Verifies the wrapper passes the expected workflow header configuration.
|
||||
describe('Rendering', () => {
|
||||
it('should configure workflow header slots and hide workflow-only controls', () => {
|
||||
render(
|
||||
<SnippetHeader
|
||||
snippetId="snippet-1"
|
||||
inputFieldCount={3}
|
||||
uiMeta={uiMeta}
|
||||
isPublishMenuOpen={false}
|
||||
isPublishing={false}
|
||||
onToggleInputPanel={mockToggleInputPanel}
|
||||
onPublishMenuOpenChange={mockPublishMenuOpenChange}
|
||||
onPublish={mockPublish}
|
||||
/>,
|
||||
)
|
||||
|
||||
const header = screen.getByTestId('workflow-header')
|
||||
expect(header).toHaveAttribute('data-show-env', 'false')
|
||||
expect(header).toHaveAttribute('data-show-global-variable', 'false')
|
||||
expect(header).toHaveAttribute('data-history-url', '/snippets/snippet-1/workflow-runs')
|
||||
expect(screen.getByRole('button', { name: /snippet\.inputFieldButton/i })).toHaveTextContent('3')
|
||||
expect(screen.getByRole('button', { name: /snippet\.publishButton/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /snippet\.testRunButton/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Verifies forwarded callbacks still drive the snippet-specific controls.
|
||||
describe('User Interactions', () => {
|
||||
it('should invoke the snippet callbacks when input and publish trigger are clicked', () => {
|
||||
render(
|
||||
<SnippetHeader
|
||||
snippetId="snippet-1"
|
||||
inputFieldCount={1}
|
||||
uiMeta={uiMeta}
|
||||
isPublishMenuOpen={false}
|
||||
isPublishing={false}
|
||||
onToggleInputPanel={mockToggleInputPanel}
|
||||
onPublishMenuOpenChange={mockPublishMenuOpenChange}
|
||||
onPublish={mockPublish}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /snippet\.inputFieldButton/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /snippet\.publishButton/i }))
|
||||
|
||||
expect(mockToggleInputPanel).toHaveBeenCalledTimes(1)
|
||||
expect(mockPublishMenuOpenChange).toHaveBeenCalledTimes(1)
|
||||
expect(mockPublishMenuOpenChange.mock.calls[0][0]).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import type { HeaderProps } from '@/app/components/workflow/header'
|
||||
import type { SnippetDetailUIModel } from '@/models/snippet'
|
||||
import {
|
||||
memo,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import Header from '@/app/components/workflow/header'
|
||||
import InputFieldButton from './input-field-button'
|
||||
import Publisher from './publisher'
|
||||
import RunMode from './run-mode'
|
||||
|
||||
type SnippetHeaderProps = {
|
||||
snippetId: string
|
||||
inputFieldCount: number
|
||||
uiMeta: SnippetDetailUIModel
|
||||
isPublishMenuOpen: boolean
|
||||
isPublishing: boolean
|
||||
onToggleInputPanel: () => void
|
||||
onPublishMenuOpenChange: (open: boolean) => void
|
||||
onPublish: () => void
|
||||
}
|
||||
|
||||
const SnippetHeader = ({
|
||||
snippetId,
|
||||
inputFieldCount,
|
||||
uiMeta,
|
||||
isPublishMenuOpen,
|
||||
isPublishing,
|
||||
onToggleInputPanel,
|
||||
onPublishMenuOpenChange,
|
||||
onPublish,
|
||||
}: SnippetHeaderProps) => {
|
||||
const viewHistoryProps = useMemo(() => {
|
||||
return {
|
||||
historyUrl: `/snippets/${snippetId}/workflow-runs`,
|
||||
}
|
||||
}, [snippetId])
|
||||
|
||||
const headerProps: HeaderProps = useMemo(() => {
|
||||
return {
|
||||
normal: {
|
||||
components: {
|
||||
left: <InputFieldButton count={inputFieldCount} onClick={onToggleInputPanel} />,
|
||||
middle: (
|
||||
<Publisher
|
||||
uiMeta={uiMeta}
|
||||
open={isPublishMenuOpen}
|
||||
isPublishing={isPublishing}
|
||||
onOpenChange={onPublishMenuOpenChange}
|
||||
onPublish={onPublish}
|
||||
/>
|
||||
),
|
||||
},
|
||||
controls: {
|
||||
showEnvButton: false,
|
||||
showGlobalVariableButton: false,
|
||||
},
|
||||
runAndHistoryProps: {
|
||||
showRunButton: true,
|
||||
viewHistoryProps,
|
||||
components: {
|
||||
RunMode,
|
||||
},
|
||||
},
|
||||
},
|
||||
viewHistory: {
|
||||
viewHistoryProps,
|
||||
},
|
||||
}
|
||||
}, [inputFieldCount, isPublishMenuOpen, isPublishing, onPublish, onPublishMenuOpenChange, onToggleInputPanel, uiMeta, viewHistoryProps])
|
||||
|
||||
return <Header {...headerProps} />
|
||||
}
|
||||
|
||||
export default memo(SnippetHeader)
|
||||
@@ -0,0 +1,32 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type InputFieldButtonProps = {
|
||||
count: number
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const InputFieldButton = ({
|
||||
count,
|
||||
onClick,
|
||||
}: InputFieldButtonProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 items-center gap-1 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 text-text-secondary shadow-xs backdrop-blur"
|
||||
onClick={onClick}
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-workflow-input-field h-4 w-4 shrink-0" />
|
||||
<span className="text-[13px] font-medium leading-4">{t('inputFieldButton')}</span>
|
||||
<span className="rounded-md border border-divider-deep px-1 py-0.5 text-[10px] font-medium leading-3 text-text-tertiary">
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(InputFieldButton)
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetDetailUIModel } from '@/models/snippet'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/app/components/base/ui/dropdown-menu'
|
||||
import PublishMenu from '../publish-menu'
|
||||
|
||||
type PublisherProps = {
|
||||
uiMeta: SnippetDetailUIModel
|
||||
open: boolean
|
||||
isPublishing: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onPublish: () => void
|
||||
}
|
||||
|
||||
const Publisher = ({
|
||||
uiMeta,
|
||||
open,
|
||||
isPublishing,
|
||||
onOpenChange,
|
||||
onPublish,
|
||||
}: PublisherProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1 rounded-lg bg-components-button-primary-bg px-3 py-2 text-white shadow-[0px_2px_2px_-1px_rgba(0,0,0,0.12),0px_1px_1px_-1px_rgba(0,0,0,0.12),0px_0px_0px_0.5px_rgba(9,9,11,0.05)]">
|
||||
<span className="text-[13px] font-medium leading-4">{t('publishButton')}</span>
|
||||
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={6}
|
||||
popupClassName="w-80 !rounded-2xl !bg-components-panel-bg !p-0 !shadow-[0px_20px_24px_-4px_rgba(9,9,11,0.08),0px_8px_8px_-4px_rgba(9,9,11,0.03)]"
|
||||
>
|
||||
<PublishMenu
|
||||
uiMeta={uiMeta}
|
||||
isPublishing={isPublishing}
|
||||
onPublish={onPublish}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Publisher)
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useWorkflowRun, useWorkflowStartRun } from '@/app/components/workflow/hooks'
|
||||
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type RunModeProps = {
|
||||
text?: string
|
||||
}
|
||||
|
||||
const RunMode = ({
|
||||
text,
|
||||
}: RunModeProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const { handleWorkflowStartRunInWorkflow } = useWorkflowStartRun()
|
||||
const { handleStopRun } = useWorkflowRun()
|
||||
const workflowRunningData = useStore(s => s.workflowRunningData)
|
||||
|
||||
const isRunning = workflowRunningData?.result.status === WorkflowRunningStatus.Running
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
handleStopRun(workflowRunningData?.task_id || '')
|
||||
}, [handleStopRun, workflowRunningData?.task_id])
|
||||
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
eventEmitter?.useSubscription((v) => {
|
||||
if (typeof v !== 'string' && v.type === EVENT_WORKFLOW_STOP)
|
||||
handleStop()
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-px">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-7 items-center gap-x-1 rounded-md px-1.5 text-components-button-secondary-accent-text system-xs-medium hover:bg-state-accent-hover',
|
||||
isRunning && 'cursor-not-allowed rounded-l-md bg-state-accent-hover',
|
||||
)}
|
||||
onClick={handleWorkflowStartRunInWorkflow}
|
||||
disabled={isRunning}
|
||||
>
|
||||
{isRunning
|
||||
? (
|
||||
<>
|
||||
<span aria-hidden className="i-ri-loader-2-line mr-1 size-4 animate-spin" />
|
||||
{t('common.running', { ns: 'workflow' })}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<span aria-hidden className="i-ri-play-large-line mr-1 size-4" />
|
||||
{text ?? t('common.run', { ns: 'workflow' })}
|
||||
<ShortcutsName keys={['alt', 'R']} textColor="secondary" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isRunning && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-7 items-center justify-center rounded-r-md bg-state-accent-active"
|
||||
onClick={handleStop}
|
||||
>
|
||||
<span aria-hidden className="i-ri-stop-circle-line size-4 text-text-accent" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(RunMode)
|
||||
@@ -0,0 +1,266 @@
|
||||
'use client'
|
||||
|
||||
import { useDebounceFn, useKeyPress } from 'ahooks'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@/app/components/base/ui/dialog'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import {
|
||||
DSLImportMode,
|
||||
DSLImportStatus,
|
||||
} from '@/models/app'
|
||||
import {
|
||||
useConfirmSnippetImportMutation,
|
||||
useImportSnippetDSLMutation,
|
||||
} from '@/service/use-snippets'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import ShortcutsName from '../../workflow/shortcuts-name'
|
||||
|
||||
type SnippetImportDSLDialogProps = {
|
||||
show: boolean
|
||||
onClose: () => void
|
||||
onSuccess?: (snippetId: string) => void
|
||||
}
|
||||
|
||||
const SnippetImportDSLTab = {
|
||||
FromFile: 'from-file',
|
||||
FromURL: 'from-url',
|
||||
} as const
|
||||
|
||||
type SnippetImportDSLTabValue = typeof SnippetImportDSLTab[keyof typeof SnippetImportDSLTab]
|
||||
|
||||
const SnippetImportDSLDialog = ({
|
||||
show,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: SnippetImportDSLDialogProps) => {
|
||||
const { t } = useTranslation()
|
||||
const importSnippetDSLMutation = useImportSnippetDSLMutation()
|
||||
const confirmSnippetImportMutation = useConfirmSnippetImportMutation()
|
||||
const [currentFile, setCurrentFile] = useState<File>()
|
||||
const [fileContent, setFileContent] = useState<string>()
|
||||
const [currentTab, setCurrentTab] = useState<SnippetImportDSLTabValue>(SnippetImportDSLTab.FromFile)
|
||||
const [dslUrlValue, setDslUrlValue] = useState('')
|
||||
const [showVersionMismatchDialog, setShowVersionMismatchDialog] = useState(false)
|
||||
const [versions, setVersions] = useState<{ importedVersion: string, systemVersion: string }>()
|
||||
const [importId, setImportId] = useState<string>()
|
||||
|
||||
const isImporting = importSnippetDSLMutation.isPending || confirmSnippetImportMutation.isPending
|
||||
|
||||
const readFile = (file: File) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const content = event.target?.result
|
||||
setFileContent(content as string)
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
const handleFile = (file?: File) => {
|
||||
setCurrentFile(file)
|
||||
if (file)
|
||||
readFile(file)
|
||||
if (!file)
|
||||
setFileContent('')
|
||||
}
|
||||
|
||||
const completeImport = (snippetId?: string, status: string = DSLImportStatus.COMPLETED) => {
|
||||
if (!snippetId) {
|
||||
toast.error(t('importFailed', { ns: 'snippet' }))
|
||||
return
|
||||
}
|
||||
|
||||
if (status === DSLImportStatus.COMPLETED_WITH_WARNINGS)
|
||||
toast.warning(t('newApp.appCreateDSLWarning', { ns: 'app' }))
|
||||
else
|
||||
toast.success(t('importSuccess', { ns: 'snippet' }))
|
||||
|
||||
onSuccess?.(snippetId)
|
||||
}
|
||||
|
||||
const handleImportResponse = (response: {
|
||||
id: string
|
||||
status: string
|
||||
snippet_id?: string
|
||||
imported_dsl_version?: string
|
||||
current_dsl_version?: string
|
||||
}) => {
|
||||
if (response.status === DSLImportStatus.COMPLETED || response.status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
|
||||
completeImport(response.snippet_id, response.status)
|
||||
return
|
||||
}
|
||||
|
||||
if (response.status === DSLImportStatus.PENDING) {
|
||||
setVersions({
|
||||
importedVersion: response.imported_dsl_version ?? '',
|
||||
systemVersion: response.current_dsl_version ?? '',
|
||||
})
|
||||
setImportId(response.id)
|
||||
setShowVersionMismatchDialog(true)
|
||||
return
|
||||
}
|
||||
|
||||
toast.error(t('importFailed', { ns: 'snippet' }))
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
if (currentTab === SnippetImportDSLTab.FromFile && !currentFile)
|
||||
return
|
||||
if (currentTab === SnippetImportDSLTab.FromURL && !dslUrlValue)
|
||||
return
|
||||
|
||||
importSnippetDSLMutation.mutate({
|
||||
mode: currentTab === SnippetImportDSLTab.FromFile ? DSLImportMode.YAML_CONTENT : DSLImportMode.YAML_URL,
|
||||
yamlContent: currentTab === SnippetImportDSLTab.FromFile ? fileContent || '' : undefined,
|
||||
yamlUrl: currentTab === SnippetImportDSLTab.FromURL ? dslUrlValue : undefined,
|
||||
}, {
|
||||
onSuccess: handleImportResponse,
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : t('importFailed', { ns: 'snippet' }))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const { run: handleCreateSnippet } = useDebounceFn(handleCreate, { wait: 300 })
|
||||
|
||||
const handleConfirmImport = () => {
|
||||
if (!importId)
|
||||
return
|
||||
|
||||
confirmSnippetImportMutation.mutate({
|
||||
importId,
|
||||
}, {
|
||||
onSuccess: (response) => {
|
||||
setShowVersionMismatchDialog(false)
|
||||
completeImport(response.snippet_id)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : t('importFailed', { ns: 'snippet' }))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
useKeyPress(['meta.enter', 'ctrl.enter'], () => {
|
||||
if (!show || showVersionMismatchDialog || isImporting)
|
||||
return
|
||||
|
||||
if ((currentTab === SnippetImportDSLTab.FromFile && currentFile) || (currentTab === SnippetImportDSLTab.FromURL && dslUrlValue))
|
||||
handleCreateSnippet()
|
||||
})
|
||||
|
||||
const buttonDisabled = useMemo(() => {
|
||||
if (isImporting)
|
||||
return true
|
||||
if (currentTab === SnippetImportDSLTab.FromFile)
|
||||
return !currentFile
|
||||
return !dslUrlValue
|
||||
}, [currentFile, currentTab, dslUrlValue, isImporting])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={show} onOpenChange={open => !open && onClose()}>
|
||||
<DialogContent className="w-[520px] p-0">
|
||||
<div className="flex items-center justify-between pb-3 pl-6 pr-5 pt-6">
|
||||
<DialogTitle className="text-text-primary title-2xl-semi-bold">
|
||||
{t('importFromDSL', { ns: 'app' })}
|
||||
</DialogTitle>
|
||||
<DialogCloseButton className="right-5 top-6 h-8 w-8" />
|
||||
</div>
|
||||
|
||||
<div className="flex h-9 items-center space-x-6 border-b border-divider-subtle px-6 text-text-tertiary system-md-semibold">
|
||||
{[
|
||||
{ key: SnippetImportDSLTab.FromFile, label: t('importFromDSLFile', { ns: 'app' }) },
|
||||
{ key: SnippetImportDSLTab.FromURL, label: t('importFromDSLUrl', { ns: 'app' }) },
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
className={cn(
|
||||
'relative flex h-full cursor-pointer items-center',
|
||||
currentTab === tab.key && 'text-text-primary',
|
||||
)}
|
||||
onClick={() => setCurrentTab(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
{currentTab === tab.key && (
|
||||
<div className="absolute bottom-0 h-[2px] w-full bg-util-colors-blue-brand-blue-brand-600" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4">
|
||||
{currentTab === SnippetImportDSLTab.FromFile && (
|
||||
<Uploader
|
||||
className="mt-0"
|
||||
file={currentFile}
|
||||
updateFile={handleFile}
|
||||
/>
|
||||
)}
|
||||
{currentTab === SnippetImportDSLTab.FromURL && (
|
||||
<div>
|
||||
<div className="mb-1 text-text-secondary system-md-semibold">DSL URL</div>
|
||||
<Input
|
||||
placeholder={t('importFromDSLUrlPlaceholder', { ns: 'app' }) || ''}
|
||||
value={dslUrlValue}
|
||||
onChange={e => setDslUrlValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end px-6 py-5">
|
||||
<Button className="mr-2" disabled={isImporting} onClick={onClose}>
|
||||
{t('newApp.Cancel', { ns: 'app' })}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={buttonDisabled}
|
||||
variant="primary"
|
||||
onClick={handleCreateSnippet}
|
||||
className="gap-1"
|
||||
>
|
||||
<span>{t('newApp.Create', { ns: 'app' })}</span>
|
||||
<ShortcutsName keys={['ctrl', '↵']} bgColor="white" />
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showVersionMismatchDialog} onOpenChange={open => !open && setShowVersionMismatchDialog(false)}>
|
||||
<DialogContent className="w-[480px]">
|
||||
<div className="flex flex-col items-start gap-2 self-stretch pb-4">
|
||||
<DialogTitle className="text-text-primary title-2xl-semi-bold">
|
||||
{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}
|
||||
</DialogTitle>
|
||||
<div className="flex grow flex-col text-text-secondary system-md-regular">
|
||||
<div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div>
|
||||
<div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div>
|
||||
<br />
|
||||
<div>
|
||||
{t('newApp.appCreateDSLErrorPart3', { ns: 'app' })}
|
||||
<span className="system-md-medium">{versions?.importedVersion}</span>
|
||||
</div>
|
||||
<div>
|
||||
{t('newApp.appCreateDSLErrorPart4', { ns: 'app' })}
|
||||
<span className="system-md-medium">{versions?.systemVersion}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start justify-end gap-2 self-stretch pt-6">
|
||||
<Button variant="secondary" disabled={isImporting} onClick={() => setShowVersionMismatchDialog(false)}>
|
||||
{t('newApp.Cancel', { ns: 'app' })}
|
||||
</Button>
|
||||
<Button variant="primary" destructive disabled={isImporting} onClick={handleConfirmImport}>
|
||||
{t('newApp.Confirm', { ns: 'app' })}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetImportDSLDialog
|
||||
88
web/app/components/snippets/components/snippet-layout.tsx
Normal file
88
web/app/components/snippets/components/snippet-layout.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { NavIcon } from '@/app/components/app-sidebar/nav-link'
|
||||
import type { SnippetDetail, SnippetSection } from '@/models/snippet'
|
||||
import {
|
||||
RiFlaskFill,
|
||||
RiFlaskLine,
|
||||
RiTerminalWindowFill,
|
||||
RiTerminalWindowLine,
|
||||
} from '@remixicon/react'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppSideBar from '@/app/components/app-sidebar'
|
||||
import NavLink from '@/app/components/app-sidebar/nav-link'
|
||||
import SnippetInfo from '@/app/components/app-sidebar/snippet-info'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
|
||||
type SnippetLayoutProps = {
|
||||
children: ReactNode
|
||||
section: SnippetSection
|
||||
snippet: SnippetDetail
|
||||
snippetId: string
|
||||
}
|
||||
|
||||
const ORCHESTRATE_ICONS: { normal: NavIcon, selected: NavIcon } = {
|
||||
normal: RiTerminalWindowLine,
|
||||
selected: RiTerminalWindowFill,
|
||||
}
|
||||
|
||||
const EVALUATION_ICONS: { normal: NavIcon, selected: NavIcon } = {
|
||||
normal: RiFlaskLine,
|
||||
selected: RiFlaskFill,
|
||||
}
|
||||
|
||||
const SnippetLayout = ({
|
||||
children,
|
||||
section,
|
||||
snippet,
|
||||
snippetId,
|
||||
}: SnippetLayoutProps) => {
|
||||
const { t } = useTranslation('snippet')
|
||||
const media = useBreakpoints()
|
||||
const isMobile = media === MediaType.mobile
|
||||
const setAppSidebarExpand = useAppStore(state => state.setAppSidebarExpand)
|
||||
|
||||
useEffect(() => {
|
||||
const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
|
||||
const mode = isMobile ? 'collapse' : 'expand'
|
||||
setAppSidebarExpand(isMobile ? mode : localeMode)
|
||||
}, [isMobile, setAppSidebarExpand])
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full overflow-hidden bg-background-body">
|
||||
<AppSideBar
|
||||
navigation={[]}
|
||||
renderHeader={mode => <SnippetInfo expand={mode === 'expand'} snippet={snippet} />}
|
||||
renderNavigation={mode => (
|
||||
<>
|
||||
<NavLink
|
||||
mode={mode}
|
||||
name={t('sectionOrchestrate')}
|
||||
iconMap={ORCHESTRATE_ICONS}
|
||||
href={`/snippets/${snippetId}/orchestrate`}
|
||||
active={section === 'orchestrate'}
|
||||
/>
|
||||
<NavLink
|
||||
mode={mode}
|
||||
name={t('sectionEvaluation')}
|
||||
iconMap={EVALUATION_ICONS}
|
||||
href={`/snippets/${snippetId}/evaluation`}
|
||||
active={section === 'evaluation'}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="relative min-h-0 min-w-0 grow overflow-hidden">
|
||||
<div className="absolute inset-0 min-h-0 min-w-0 overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetLayout
|
||||
215
web/app/components/snippets/components/snippet-main.tsx
Normal file
215
web/app/components/snippets/components/snippet-main.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
'use client'
|
||||
|
||||
import type { WorkflowProps } from '@/app/components/workflow'
|
||||
import type { SnippetDetailPayload } from '@/models/snippet'
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { WorkflowWithInnerContext } from '@/app/components/workflow'
|
||||
import { useAvailableNodesMetaData } from '@/app/components/workflow-app/hooks'
|
||||
import { useSetWorkflowVarsWithValue } from '@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useConfigsMap } from '../hooks/use-configs-map'
|
||||
import { useInspectVarsCrud } from '../hooks/use-inspect-vars-crud'
|
||||
import { useNodesSyncDraft } from '../hooks/use-nodes-sync-draft'
|
||||
import { useSnippetRefreshDraft } from '../hooks/use-snippet-refresh-draft'
|
||||
import { useSnippetRun } from '../hooks/use-snippet-run'
|
||||
import { useSnippetStartRun } from '../hooks/use-snippet-start-run'
|
||||
import { useSnippetDetailStore } from '../store'
|
||||
import { useSnippetInputFieldActions } from './hooks/use-snippet-input-field-actions'
|
||||
import { useSnippetPublish } from './hooks/use-snippet-publish'
|
||||
import SnippetChildren from './snippet-children'
|
||||
|
||||
type SnippetMainProps = {
|
||||
payload: SnippetDetailPayload
|
||||
snippetId: string
|
||||
} & Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport'>
|
||||
|
||||
const SnippetMain = ({
|
||||
payload,
|
||||
snippetId,
|
||||
nodes,
|
||||
edges,
|
||||
viewport,
|
||||
}: SnippetMainProps) => {
|
||||
const { graph, uiMeta } = payload
|
||||
const {
|
||||
doSyncWorkflowDraft,
|
||||
syncWorkflowDraftWhenPageClose,
|
||||
} = useNodesSyncDraft(snippetId)
|
||||
const { handleRefreshWorkflowDraft } = useSnippetRefreshDraft(snippetId)
|
||||
const {
|
||||
handleBackupDraft,
|
||||
handleLoadBackupDraft,
|
||||
handleRestoreFromPublishedWorkflow,
|
||||
handleRun,
|
||||
handleStopRun,
|
||||
} = useSnippetRun(snippetId)
|
||||
const configsMap = useConfigsMap(snippetId)
|
||||
const { fetchInspectVars } = useSetWorkflowVarsWithValue({
|
||||
...configsMap,
|
||||
})
|
||||
const {
|
||||
hasNodeInspectVars,
|
||||
hasSetInspectVar,
|
||||
fetchInspectVarValue,
|
||||
editInspectVarValue,
|
||||
renameInspectVarName,
|
||||
appendNodeInspectVars,
|
||||
deleteInspectVar,
|
||||
deleteNodeInspectorVars,
|
||||
deleteAllInspectorVars,
|
||||
isInspectVarEdited,
|
||||
resetToLastRunVar,
|
||||
invalidateSysVarValues,
|
||||
resetConversationVar,
|
||||
invalidateConversationVarValues,
|
||||
} = useInspectVarsCrud(snippetId)
|
||||
const workflowAvailableNodesMetaData = useAvailableNodesMetaData()
|
||||
const availableNodesMetaData = useMemo(() => {
|
||||
const nodes = workflowAvailableNodesMetaData.nodes.filter(node =>
|
||||
node.metaData.type !== BlockEnum.HumanInput && node.metaData.type !== BlockEnum.End)
|
||||
|
||||
if (!workflowAvailableNodesMetaData.nodesMap)
|
||||
return { nodes }
|
||||
|
||||
const {
|
||||
[BlockEnum.HumanInput]: _humanInput,
|
||||
[BlockEnum.End]: _end,
|
||||
...nodesMap
|
||||
} = workflowAvailableNodesMetaData.nodesMap
|
||||
|
||||
return {
|
||||
nodes,
|
||||
nodesMap,
|
||||
}
|
||||
}, [workflowAvailableNodesMetaData])
|
||||
const reset = useSnippetDetailStore(state => state.reset)
|
||||
const {
|
||||
editingField,
|
||||
fields,
|
||||
isEditorOpen,
|
||||
isInputPanelOpen,
|
||||
openEditor,
|
||||
closeEditor,
|
||||
handleCloseInputPanel,
|
||||
handleRemoveField,
|
||||
handleSortChange,
|
||||
handleSubmitField,
|
||||
handleToggleInputPanel,
|
||||
} = useSnippetInputFieldActions({
|
||||
snippetId,
|
||||
initialFields: payload.inputFields,
|
||||
})
|
||||
const {
|
||||
handlePublish,
|
||||
isPublishMenuOpen,
|
||||
isPublishing,
|
||||
setPublishMenuOpen,
|
||||
} = useSnippetPublish({
|
||||
snippetId,
|
||||
})
|
||||
const {
|
||||
handleStartWorkflowRun,
|
||||
handleWorkflowStartRunInWorkflow,
|
||||
} = useSnippetStartRun({
|
||||
handleRun,
|
||||
inputFields: fields,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
reset()
|
||||
}, [reset, snippetId])
|
||||
|
||||
const hooksStore = useMemo(() => {
|
||||
return {
|
||||
doSyncWorkflowDraft,
|
||||
syncWorkflowDraftWhenPageClose,
|
||||
handleRefreshWorkflowDraft,
|
||||
handleBackupDraft,
|
||||
handleLoadBackupDraft,
|
||||
handleRestoreFromPublishedWorkflow,
|
||||
handleRun,
|
||||
handleStopRun,
|
||||
handleStartWorkflowRun,
|
||||
handleWorkflowStartRunInWorkflow,
|
||||
availableNodesMetaData,
|
||||
fetchInspectVars,
|
||||
hasNodeInspectVars,
|
||||
hasSetInspectVar,
|
||||
fetchInspectVarValue,
|
||||
editInspectVarValue,
|
||||
renameInspectVarName,
|
||||
appendNodeInspectVars,
|
||||
deleteInspectVar,
|
||||
deleteNodeInspectorVars,
|
||||
deleteAllInspectorVars,
|
||||
isInspectVarEdited,
|
||||
resetToLastRunVar,
|
||||
invalidateSysVarValues,
|
||||
resetConversationVar,
|
||||
invalidateConversationVarValues,
|
||||
configsMap,
|
||||
}
|
||||
}, [
|
||||
appendNodeInspectVars,
|
||||
availableNodesMetaData,
|
||||
configsMap,
|
||||
deleteAllInspectorVars,
|
||||
deleteInspectVar,
|
||||
deleteNodeInspectorVars,
|
||||
doSyncWorkflowDraft,
|
||||
editInspectVarValue,
|
||||
fetchInspectVarValue,
|
||||
fetchInspectVars,
|
||||
handleBackupDraft,
|
||||
handleRefreshWorkflowDraft,
|
||||
handleLoadBackupDraft,
|
||||
handleRestoreFromPublishedWorkflow,
|
||||
handleRun,
|
||||
handleStartWorkflowRun,
|
||||
handleStopRun,
|
||||
handleWorkflowStartRunInWorkflow,
|
||||
hasNodeInspectVars,
|
||||
hasSetInspectVar,
|
||||
invalidateConversationVarValues,
|
||||
invalidateSysVarValues,
|
||||
isInspectVarEdited,
|
||||
renameInspectVarName,
|
||||
resetConversationVar,
|
||||
resetToLastRunVar,
|
||||
syncWorkflowDraftWhenPageClose,
|
||||
])
|
||||
|
||||
return (
|
||||
<WorkflowWithInnerContext
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
viewport={viewport ?? graph.viewport}
|
||||
hooksStore={hooksStore as any}
|
||||
>
|
||||
<SnippetChildren
|
||||
snippetId={snippetId}
|
||||
fields={fields}
|
||||
uiMeta={uiMeta}
|
||||
editingField={editingField}
|
||||
isEditorOpen={isEditorOpen}
|
||||
isInputPanelOpen={isInputPanelOpen}
|
||||
isPublishMenuOpen={isPublishMenuOpen}
|
||||
isPublishing={isPublishing}
|
||||
onToggleInputPanel={handleToggleInputPanel}
|
||||
onPublishMenuOpenChange={setPublishMenuOpen}
|
||||
onCloseInputPanel={handleCloseInputPanel}
|
||||
onPublish={handlePublish}
|
||||
onOpenEditor={openEditor}
|
||||
onCloseEditor={closeEditor}
|
||||
onSubmitField={handleSubmitField}
|
||||
onRemoveField={handleRemoveField}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
</WorkflowWithInnerContext>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetMain
|
||||
293
web/app/components/snippets/components/snippet-run-panel.tsx
Normal file
293
web/app/components/snippets/components/snippet-run-panel.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
'use client'
|
||||
|
||||
import type { InputForm } from '@/app/components/base/chat/chat/type'
|
||||
import type { InputVar as WorkflowInputVar } from '@/app/components/workflow/types'
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useCheckInputsForms } from '@/app/components/base/chat/chat/check-input-forms-hooks'
|
||||
import { getProcessedInputs } from '@/app/components/base/chat/chat/utils'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import {
|
||||
useWorkflowInteractions,
|
||||
useWorkflowRun,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import FormItem from '@/app/components/workflow/nodes/_base/components/before-run-form/form-item'
|
||||
import ResultPanel from '@/app/components/workflow/run/result-panel'
|
||||
import ResultText from '@/app/components/workflow/run/result-text'
|
||||
import TracingPanel from '@/app/components/workflow/run/tracing-panel'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import {
|
||||
InputVarType,
|
||||
WorkflowRunningStatus,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { formatWorkflowRunIdentifier } from '@/app/components/workflow/utils'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
|
||||
type SnippetRunPanelProps = {
|
||||
fields: SnippetInputField[]
|
||||
}
|
||||
|
||||
type SnippetRunField = WorkflowInputVar & InputForm
|
||||
|
||||
const PIPELINE_TO_WORKFLOW_INPUT_VAR_TYPE: Record<PipelineInputVarType, InputVarType> = {
|
||||
[PipelineInputVarType.textInput]: InputVarType.textInput,
|
||||
[PipelineInputVarType.paragraph]: InputVarType.paragraph,
|
||||
[PipelineInputVarType.select]: InputVarType.select,
|
||||
[PipelineInputVarType.number]: InputVarType.number,
|
||||
[PipelineInputVarType.singleFile]: InputVarType.singleFile,
|
||||
[PipelineInputVarType.multiFiles]: InputVarType.multiFiles,
|
||||
[PipelineInputVarType.checkbox]: InputVarType.checkbox,
|
||||
}
|
||||
|
||||
const buildPreviewFields = (fields: SnippetInputField[]): SnippetRunField[] => {
|
||||
return fields.map(field => ({
|
||||
type: PIPELINE_TO_WORKFLOW_INPUT_VAR_TYPE[field.type],
|
||||
label: field.label,
|
||||
variable: field.variable,
|
||||
max_length: field.max_length,
|
||||
default: field.default_value,
|
||||
required: field.required,
|
||||
options: field.options,
|
||||
placeholder: field.placeholder,
|
||||
unit: field.unit,
|
||||
hide: false,
|
||||
allowed_file_upload_methods: field.allowed_file_upload_methods,
|
||||
allowed_file_types: field.allowed_file_types,
|
||||
allowed_file_extensions: field.allowed_file_extensions,
|
||||
}))
|
||||
}
|
||||
|
||||
const buildInitialInputs = (fields: SnippetRunField[]) => {
|
||||
return fields.reduce<Record<string, unknown>>((acc, field) => {
|
||||
if (field.default !== undefined)
|
||||
acc[field.variable] = field.default
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
const SnippetRunPanel = ({
|
||||
fields,
|
||||
}: SnippetRunPanelProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions()
|
||||
const { handleRun } = useWorkflowRun()
|
||||
const { checkInputsForm } = useCheckInputsForms()
|
||||
const workflowRunningData = useStore(s => s.workflowRunningData)
|
||||
const showInputsPanel = useStore(s => s.showInputsPanel)
|
||||
const workflowCanvasWidth = useStore(s => s.workflowCanvasWidth)
|
||||
const panelWidth = useStore(s => s.previewPanelWidth)
|
||||
const setPreviewPanelWidth = useStore(s => s.setPreviewPanelWidth)
|
||||
|
||||
const previewFields = useMemo(() => buildPreviewFields(fields), [fields])
|
||||
const initialInputs = useMemo(() => buildInitialInputs(previewFields), [previewFields])
|
||||
const [inputOverrides, setInputOverrides] = useState<Record<string, unknown> | null>(null)
|
||||
const [selectedTab, setSelectedTab] = useState<string | null>(null)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
|
||||
const inputs = inputOverrides ?? initialInputs
|
||||
const hasInputTab = showInputsPanel && previewFields.length > 0
|
||||
const defaultTab = hasInputTab ? 'INPUT' : 'RESULT'
|
||||
const shouldShowDetailByDefault = !!workflowRunningData
|
||||
&& (workflowRunningData.result.status === WorkflowRunningStatus.Succeeded || workflowRunningData.result.status === WorkflowRunningStatus.Failed)
|
||||
&& !workflowRunningData.resultText
|
||||
&& !workflowRunningData.result.files?.length
|
||||
const currentTab = selectedTab ?? (shouldShowDetailByDefault ? 'DETAIL' : defaultTab)
|
||||
|
||||
const handleValueChange = useCallback((variable: string, value: unknown) => {
|
||||
setInputOverrides(prev => ({
|
||||
...(prev ?? initialInputs),
|
||||
[variable]: value,
|
||||
}))
|
||||
}, [initialInputs])
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!checkInputsForm(inputs, previewFields))
|
||||
return
|
||||
|
||||
setSelectedTab('RESULT')
|
||||
handleRun({
|
||||
inputs: getProcessedInputs(inputs, previewFields),
|
||||
})
|
||||
}, [checkInputsForm, handleRun, inputs, previewFields])
|
||||
|
||||
const startResizing = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setIsResizing(true)
|
||||
}, [])
|
||||
|
||||
const stopResizing = useCallback(() => {
|
||||
setIsResizing(false)
|
||||
}, [])
|
||||
|
||||
const resize = useCallback((e: MouseEvent) => {
|
||||
if (!isResizing)
|
||||
return
|
||||
|
||||
const newWidth = window.innerWidth - e.clientX
|
||||
const reservedCanvasWidth = 400
|
||||
const maxAllowed = workflowCanvasWidth ? (workflowCanvasWidth - reservedCanvasWidth) : 1024
|
||||
|
||||
if (newWidth >= 400 && newWidth <= maxAllowed)
|
||||
setPreviewPanelWidth(newWidth)
|
||||
}, [isResizing, setPreviewPanelWidth, workflowCanvasWidth])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('mousemove', resize)
|
||||
window.addEventListener('mouseup', stopResizing)
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', resize)
|
||||
window.removeEventListener('mouseup', stopResizing)
|
||||
}
|
||||
}, [resize, stopResizing])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex h-full flex-col rounded-l-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl"
|
||||
style={{ width: `${panelWidth}px` }}
|
||||
>
|
||||
<div
|
||||
className="absolute bottom-0 left-[3px] top-1/2 z-50 h-6 w-[3px] cursor-col-resize rounded bg-gray-300"
|
||||
onMouseDown={startResizing}
|
||||
/>
|
||||
<div className="flex items-center justify-between p-4 pb-1 text-base font-semibold text-text-primary">
|
||||
{`Test Run${formatWorkflowRunIdentifier(workflowRunningData?.result.finished_at, workflowRunningData?.result.status)}`}
|
||||
<div className="cursor-pointer p-1" onClick={handleCancelDebugAndPreviewPanel}>
|
||||
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex grow flex-col">
|
||||
<div className="flex shrink-0 items-center border-b-[0.5px] border-divider-subtle px-4">
|
||||
{hasInputTab && (
|
||||
<div
|
||||
className={`mr-6 cursor-pointer border-b-2 py-3 text-[13px] font-semibold leading-[18px] ${currentTab === 'INPUT' ? '!border-[rgb(21,94,239)] text-text-secondary' : 'border-transparent text-text-tertiary'}`}
|
||||
onClick={() => setSelectedTab('INPUT')}
|
||||
>
|
||||
{t('input', { ns: 'runLog' })}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`mr-6 cursor-pointer border-b-2 py-3 text-[13px] font-semibold leading-[18px] ${currentTab === 'RESULT' ? '!border-[rgb(21,94,239)] text-text-secondary' : 'border-transparent text-text-tertiary'} ${!workflowRunningData ? '!cursor-not-allowed opacity-30' : ''}`}
|
||||
onClick={() => workflowRunningData && setSelectedTab('RESULT')}
|
||||
>
|
||||
{t('result', { ns: 'runLog' })}
|
||||
</div>
|
||||
<div
|
||||
className={`mr-6 cursor-pointer border-b-2 py-3 text-[13px] font-semibold leading-[18px] ${currentTab === 'DETAIL' ? '!border-[rgb(21,94,239)] text-text-secondary' : 'border-transparent text-text-tertiary'} ${!workflowRunningData ? '!cursor-not-allowed opacity-30' : ''}`}
|
||||
onClick={() => workflowRunningData && setSelectedTab('DETAIL')}
|
||||
>
|
||||
{t('detail', { ns: 'runLog' })}
|
||||
</div>
|
||||
<div
|
||||
className={`mr-6 cursor-pointer border-b-2 py-3 text-[13px] font-semibold leading-[18px] ${currentTab === 'TRACING' ? '!border-[rgb(21,94,239)] text-text-secondary' : 'border-transparent text-text-tertiary'} ${!workflowRunningData ? '!cursor-not-allowed opacity-30' : ''}`}
|
||||
onClick={() => workflowRunningData && setSelectedTab('TRACING')}
|
||||
>
|
||||
{t('tracing', { ns: 'runLog' })}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`h-0 grow overflow-y-auto rounded-b-2xl ${(currentTab === 'RESULT' || currentTab === 'TRACING') ? '!bg-background-section-burn' : 'bg-components-panel-bg'}`}>
|
||||
{currentTab === 'INPUT' && hasInputTab && (
|
||||
<>
|
||||
<div className="px-4 pb-2 pt-3">
|
||||
{previewFields.map((field, index) => (
|
||||
<div
|
||||
key={field.variable}
|
||||
className="mb-2 last-of-type:mb-0"
|
||||
>
|
||||
<FormItem
|
||||
autoFocus={index === 0}
|
||||
className="!block"
|
||||
payload={field}
|
||||
value={inputs[field.variable]}
|
||||
onChange={value => handleValueChange(field.variable, value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
disabled={workflowRunningData?.result?.status === WorkflowRunningStatus.Running}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{t('singleRun.startRun', { ns: 'workflow' })}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{currentTab === 'RESULT' && (
|
||||
<div className="p-2">
|
||||
<ResultText
|
||||
isRunning={workflowRunningData?.result?.status === WorkflowRunningStatus.Running || !workflowRunningData?.result}
|
||||
outputs={workflowRunningData?.resultText}
|
||||
allFiles={workflowRunningData?.result?.files}
|
||||
error={workflowRunningData?.result?.error}
|
||||
onClick={() => setSelectedTab('DETAIL')}
|
||||
/>
|
||||
{(workflowRunningData?.result.status === WorkflowRunningStatus.Succeeded && workflowRunningData?.resultText && typeof workflowRunningData.resultText === 'string') && (
|
||||
<Button
|
||||
className="mb-4 ml-4 space-x-1"
|
||||
onClick={() => {
|
||||
copy(workflowRunningData?.resultText || '')
|
||||
toast.success(t('actionMsg.copySuccessfully', { ns: 'common' }))
|
||||
}}
|
||||
>
|
||||
<span className="i-ri-clipboard-line h-3.5 w-3.5" />
|
||||
<div>{t('operation.copy', { ns: 'common' })}</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{currentTab === 'DETAIL' && workflowRunningData?.result && (
|
||||
<ResultPanel
|
||||
inputs={workflowRunningData.result?.inputs}
|
||||
inputs_truncated={workflowRunningData.result?.inputs_truncated}
|
||||
process_data={workflowRunningData.result?.process_data}
|
||||
process_data_truncated={workflowRunningData.result?.process_data_truncated}
|
||||
outputs={workflowRunningData.result?.outputs}
|
||||
outputs_truncated={workflowRunningData.result?.outputs_truncated}
|
||||
outputs_full_content={workflowRunningData.result?.outputs_full_content}
|
||||
status={workflowRunningData.result?.status || ''}
|
||||
error={workflowRunningData.result?.error}
|
||||
elapsed_time={workflowRunningData.result?.elapsed_time}
|
||||
total_tokens={workflowRunningData.result?.total_tokens}
|
||||
created_at={workflowRunningData.result?.created_at}
|
||||
created_by={workflowRunningData.result?.created_by}
|
||||
steps={workflowRunningData.result?.total_steps}
|
||||
exceptionCounts={workflowRunningData.result?.exceptions_count}
|
||||
/>
|
||||
)}
|
||||
{currentTab === 'DETAIL' && !workflowRunningData?.result && (
|
||||
<div className="flex h-full items-center justify-center bg-components-panel-bg">
|
||||
<Loading />
|
||||
</div>
|
||||
)}
|
||||
{currentTab === 'TRACING' && (
|
||||
<TracingPanel
|
||||
className="bg-background-section-burn"
|
||||
list={workflowRunningData?.tracing || []}
|
||||
/>
|
||||
)}
|
||||
{currentTab === 'TRACING' && !workflowRunningData?.tracing?.length && (
|
||||
<div className="flex h-full items-center justify-center !bg-background-section-burn">
|
||||
<Loading />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(SnippetRunPanel)
|
||||
145
web/app/components/snippets/components/workflow-panel.tsx
Normal file
145
web/app/components/snippets/components/workflow-panel.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
'use client'
|
||||
|
||||
import type { PanelProps } from '@/app/components/workflow/panel'
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import { memo, useMemo } from 'react'
|
||||
import Panel from '@/app/components/workflow/panel'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import SnippetInputFieldEditor from './input-field-editor'
|
||||
import SnippetInputFieldPanel from './panel'
|
||||
|
||||
const Record = dynamic(() => import('@/app/components/workflow/panel/record'), {
|
||||
ssr: false,
|
||||
})
|
||||
const SnippetRunPanel = dynamic(() => import('./snippet-run-panel'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
type SnippetWorkflowPanelProps = {
|
||||
snippetId: string
|
||||
fields: SnippetInputField[]
|
||||
editingField: SnippetInputField | null
|
||||
isEditorOpen: boolean
|
||||
isInputPanelOpen: boolean
|
||||
onCloseInputPanel: () => void
|
||||
onOpenEditor: (field?: SnippetInputField | null) => void
|
||||
onCloseEditor: () => void
|
||||
onSubmitField: (field: SnippetInputField) => void
|
||||
onRemoveField: (index: number) => void
|
||||
onSortChange: (fields: SnippetInputField[]) => void
|
||||
}
|
||||
|
||||
const SnippetPanelOnLeft = ({
|
||||
fields,
|
||||
editingField,
|
||||
isEditorOpen,
|
||||
isInputPanelOpen,
|
||||
onCloseInputPanel,
|
||||
onOpenEditor,
|
||||
onCloseEditor,
|
||||
onSubmitField,
|
||||
onRemoveField,
|
||||
onSortChange,
|
||||
}: SnippetWorkflowPanelProps) => {
|
||||
return (
|
||||
<div className="hidden xl:flex">
|
||||
{isEditorOpen && (
|
||||
<SnippetInputFieldEditor
|
||||
field={editingField}
|
||||
onClose={onCloseEditor}
|
||||
onSubmit={onSubmitField}
|
||||
/>
|
||||
)}
|
||||
{isInputPanelOpen && (
|
||||
<SnippetInputFieldPanel
|
||||
fields={fields}
|
||||
onClose={onCloseInputPanel}
|
||||
onAdd={() => onOpenEditor()}
|
||||
onEdit={onOpenEditor}
|
||||
onRemove={onRemoveField}
|
||||
onSortChange={onSortChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SnippetPanelOnRight = ({
|
||||
fields,
|
||||
}: Pick<SnippetWorkflowPanelProps, 'fields'>) => {
|
||||
const historyWorkflowData = useStore(s => s.historyWorkflowData)
|
||||
const showDebugAndPreviewPanel = useStore(s => s.showDebugAndPreviewPanel)
|
||||
|
||||
return (
|
||||
<>
|
||||
{historyWorkflowData && <Record />}
|
||||
{showDebugAndPreviewPanel && <SnippetRunPanel fields={fields} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SnippetWorkflowPanel = ({
|
||||
snippetId,
|
||||
fields,
|
||||
editingField,
|
||||
isEditorOpen,
|
||||
isInputPanelOpen,
|
||||
onCloseInputPanel,
|
||||
onOpenEditor,
|
||||
onCloseEditor,
|
||||
onSubmitField,
|
||||
onRemoveField,
|
||||
onSortChange,
|
||||
}: SnippetWorkflowPanelProps) => {
|
||||
const versionHistoryPanelProps = useMemo(() => {
|
||||
return {
|
||||
getVersionListUrl: `/snippets/${snippetId}/workflows`,
|
||||
deleteVersionUrl: (versionId: string) => `/snippets/${snippetId}/workflows/${versionId}`,
|
||||
restoreVersionUrl: (versionId: string) => `/snippets/${snippetId}/workflows/${versionId}/restore`,
|
||||
updateVersionUrl: (versionId: string) => `/snippets/${snippetId}/workflows/${versionId}`,
|
||||
latestVersionId: '',
|
||||
}
|
||||
}, [snippetId])
|
||||
|
||||
const panelProps: PanelProps = useMemo(() => {
|
||||
return {
|
||||
components: {
|
||||
left: (
|
||||
<SnippetPanelOnLeft
|
||||
snippetId={snippetId}
|
||||
fields={fields}
|
||||
editingField={editingField}
|
||||
isEditorOpen={isEditorOpen}
|
||||
isInputPanelOpen={isInputPanelOpen}
|
||||
onCloseInputPanel={onCloseInputPanel}
|
||||
onOpenEditor={onOpenEditor}
|
||||
onCloseEditor={onCloseEditor}
|
||||
onSubmitField={onSubmitField}
|
||||
onRemoveField={onRemoveField}
|
||||
onSortChange={onSortChange}
|
||||
/>
|
||||
),
|
||||
right: <SnippetPanelOnRight fields={fields} />,
|
||||
},
|
||||
versionHistoryPanelProps,
|
||||
}
|
||||
}, [
|
||||
editingField,
|
||||
fields,
|
||||
isEditorOpen,
|
||||
isInputPanelOpen,
|
||||
onCloseEditor,
|
||||
onCloseInputPanel,
|
||||
onOpenEditor,
|
||||
onRemoveField,
|
||||
onSortChange,
|
||||
onSubmitField,
|
||||
snippetId,
|
||||
versionHistoryPanelProps,
|
||||
])
|
||||
|
||||
return <Panel {...panelProps} />
|
||||
}
|
||||
|
||||
export default memo(SnippetWorkflowPanel)
|
||||
@@ -0,0 +1,95 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useInspectVarsCrud } from '../use-inspect-vars-crud'
|
||||
|
||||
const mockApis = {
|
||||
hasNodeInspectVars: vi.fn(),
|
||||
hasSetInspectVar: vi.fn(),
|
||||
fetchInspectVarValue: vi.fn(),
|
||||
editInspectVarValue: vi.fn(),
|
||||
renameInspectVarName: vi.fn(),
|
||||
appendNodeInspectVars: vi.fn(),
|
||||
deleteInspectVar: vi.fn(),
|
||||
deleteNodeInspectorVars: vi.fn(),
|
||||
deleteAllInspectorVars: vi.fn(),
|
||||
isInspectVarEdited: vi.fn(),
|
||||
resetToLastRunVar: vi.fn(),
|
||||
invalidateSysVarValues: vi.fn(),
|
||||
resetConversationVar: vi.fn(),
|
||||
invalidateConversationVarValues: vi.fn(),
|
||||
}
|
||||
|
||||
const mockUseInspectVarsCrudCommon = vi.fn(() => mockApis)
|
||||
vi.mock('../../../workflow/hooks/use-inspect-vars-crud-common', () => ({
|
||||
useInspectVarsCrudCommon: (...args: Parameters<typeof mockUseInspectVarsCrudCommon>) => mockUseInspectVarsCrudCommon(...args),
|
||||
}))
|
||||
|
||||
const mockConfigsMap = {
|
||||
flowId: 'snippet-123',
|
||||
flowType: 'snippet',
|
||||
fileSettings: {
|
||||
image: { enabled: false },
|
||||
fileUploadConfig: {},
|
||||
},
|
||||
}
|
||||
|
||||
vi.mock('../use-configs-map', () => ({
|
||||
useConfigsMap: () => mockConfigsMap,
|
||||
}))
|
||||
|
||||
describe('useInspectVarsCrud', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Composition', () => {
|
||||
it('should pass configsMap to useInspectVarsCrudCommon', () => {
|
||||
renderHook(() => useInspectVarsCrud('snippet-123'))
|
||||
|
||||
expect(mockUseInspectVarsCrudCommon).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
flowId: 'snippet-123',
|
||||
flowType: 'snippet',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should return all APIs from useInspectVarsCrudCommon', () => {
|
||||
const { result } = renderHook(() => useInspectVarsCrud('snippet-123'))
|
||||
|
||||
expect(result.current.hasNodeInspectVars).toBe(mockApis.hasNodeInspectVars)
|
||||
expect(result.current.fetchInspectVarValue).toBe(mockApis.fetchInspectVarValue)
|
||||
expect(result.current.editInspectVarValue).toBe(mockApis.editInspectVarValue)
|
||||
expect(result.current.deleteInspectVar).toBe(mockApis.deleteInspectVar)
|
||||
expect(result.current.deleteAllInspectorVars).toBe(mockApis.deleteAllInspectorVars)
|
||||
expect(result.current.resetToLastRunVar).toBe(mockApis.resetToLastRunVar)
|
||||
expect(result.current.resetConversationVar).toBe(mockApis.resetConversationVar)
|
||||
})
|
||||
})
|
||||
|
||||
describe('API Surface', () => {
|
||||
it('should expose all expected API methods', () => {
|
||||
const { result } = renderHook(() => useInspectVarsCrud('snippet-123'))
|
||||
|
||||
const expectedKeys = [
|
||||
'hasNodeInspectVars',
|
||||
'hasSetInspectVar',
|
||||
'fetchInspectVarValue',
|
||||
'editInspectVarValue',
|
||||
'renameInspectVarName',
|
||||
'appendNodeInspectVars',
|
||||
'deleteInspectVar',
|
||||
'deleteNodeInspectorVars',
|
||||
'deleteAllInspectorVars',
|
||||
'isInspectVarEdited',
|
||||
'resetToLastRunVar',
|
||||
'invalidateSysVarValues',
|
||||
'resetConversationVar',
|
||||
'invalidateConversationVarValues',
|
||||
]
|
||||
|
||||
for (const key of expectedKeys)
|
||||
expect(result.current).toHaveProperty(key)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useSnippetInit } from '../use-snippet-init'
|
||||
|
||||
const mockWorkflowStoreSetState = vi.fn()
|
||||
const mockSetPublishedAt = vi.fn()
|
||||
const mockSetDraftUpdatedAt = vi.fn()
|
||||
const mockSetSyncWorkflowDraftHash = vi.fn()
|
||||
const mockUseSnippetApiDetail = vi.fn()
|
||||
const mockUseSnippetDraftWorkflow = vi.fn()
|
||||
const mockUseSnippetDefaultBlockConfigs = vi.fn()
|
||||
const mockUseSnippetPublishedWorkflow = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
setState: mockWorkflowStoreSetState,
|
||||
getState: () => ({
|
||||
setDraftUpdatedAt: mockSetDraftUpdatedAt,
|
||||
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
|
||||
setPublishedAt: mockSetPublishedAt,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippets', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/service/use-snippets')>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useSnippetApiDetail: (snippetId: string) => mockUseSnippetApiDetail(snippetId),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/use-snippet-workflows', () => ({
|
||||
useSnippetDraftWorkflow: (snippetId: string, onSuccess?: (data: { updated_at: number, hash: string }) => void) => mockUseSnippetDraftWorkflow(snippetId, onSuccess),
|
||||
useSnippetDefaultBlockConfigs: (snippetId: string, onSuccess?: (data: unknown) => void) => mockUseSnippetDefaultBlockConfigs(snippetId, onSuccess),
|
||||
useSnippetPublishedWorkflow: (snippetId: string, onSuccess?: (data: { created_at: number }) => void) => mockUseSnippetPublishedWorkflow(snippetId, onSuccess),
|
||||
}))
|
||||
|
||||
describe('useSnippetInit', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockUseSnippetApiDetail.mockReturnValue({
|
||||
data: {
|
||||
id: 'snippet-1',
|
||||
name: 'Tone Rewriter',
|
||||
description: 'A static snippet mock.',
|
||||
type: 'node',
|
||||
is_published: false,
|
||||
version: '1',
|
||||
use_count: 0,
|
||||
icon_info: {
|
||||
icon_type: null,
|
||||
icon: '🪄',
|
||||
icon_background: '#E0EAFF',
|
||||
},
|
||||
input_fields: [],
|
||||
created_at: 1_712_300_000,
|
||||
updated_at: 1_712_300_000,
|
||||
author: 'Evan',
|
||||
},
|
||||
error: null,
|
||||
isLoading: false,
|
||||
})
|
||||
mockUseSnippetDraftWorkflow.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
})
|
||||
mockUseSnippetDefaultBlockConfigs.mockReturnValue({
|
||||
data: undefined,
|
||||
})
|
||||
mockUseSnippetPublishedWorkflow.mockReturnValue({
|
||||
data: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('should return snippet detail query result', () => {
|
||||
const { result } = renderHook(() => useSnippetInit('snippet-1'))
|
||||
|
||||
expect(mockUseSnippetApiDetail).toHaveBeenCalledWith('snippet-1')
|
||||
expect(result.current.data?.snippet.id).toBe('snippet-1')
|
||||
expect(result.current.data?.graph.viewport).toEqual({ x: 0, y: 0, zoom: 1 })
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('should use draft input_fields for snippet inputs', () => {
|
||||
mockUseSnippetApiDetail.mockReturnValue({
|
||||
data: {
|
||||
id: 'snippet-1',
|
||||
name: 'Tone Rewriter',
|
||||
description: 'A static snippet mock.',
|
||||
type: 'node',
|
||||
is_published: false,
|
||||
version: '1',
|
||||
use_count: 0,
|
||||
icon_info: {
|
||||
icon_type: null,
|
||||
icon: '🪄',
|
||||
icon_background: '#E0EAFF',
|
||||
},
|
||||
input_fields: [
|
||||
{
|
||||
label: 'Published field',
|
||||
variable: 'published_field',
|
||||
type: 'text-input',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
created_at: 1_712_300_000,
|
||||
updated_at: 1_712_300_000,
|
||||
author: 'Evan',
|
||||
},
|
||||
error: null,
|
||||
isLoading: false,
|
||||
})
|
||||
mockUseSnippetDraftWorkflow.mockReturnValue({
|
||||
data: {
|
||||
id: 'draft-1',
|
||||
graph: {},
|
||||
features: {},
|
||||
input_fields: [
|
||||
{
|
||||
label: 'Draft field',
|
||||
variable: 'draft_field',
|
||||
type: 'text-input',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
hash: 'draft-hash',
|
||||
created_at: 1_712_300_000,
|
||||
updated_at: 1_712_345_678,
|
||||
},
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useSnippetInit('snippet-1'))
|
||||
|
||||
expect(result.current.data?.inputFields).toEqual([
|
||||
{
|
||||
label: 'Draft field',
|
||||
variable: 'draft_field',
|
||||
type: 'text-input',
|
||||
required: true,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should sync draft metadata into workflow store', () => {
|
||||
mockUseSnippetDraftWorkflow.mockImplementation((_snippetId: string, onSuccess?: (data: { updated_at: number, hash: string }) => void) => {
|
||||
onSuccess?.({
|
||||
updated_at: 1_712_345_678,
|
||||
hash: 'draft-hash',
|
||||
})
|
||||
return { data: undefined, isLoading: false }
|
||||
})
|
||||
|
||||
renderHook(() => useSnippetInit('snippet-1'))
|
||||
|
||||
expect(mockSetDraftUpdatedAt).toHaveBeenCalledWith(1_712_345_678)
|
||||
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('draft-hash')
|
||||
})
|
||||
|
||||
it('should normalize array default block configs into workflow store state', () => {
|
||||
mockUseSnippetDefaultBlockConfigs.mockImplementation((_snippetId: string, onSuccess?: (data: unknown) => void) => {
|
||||
onSuccess?.([
|
||||
{ type: 'llm', config: { model: 'gpt-4.1' } },
|
||||
{ type: 'code', config: { language: 'python3' } },
|
||||
])
|
||||
return { data: undefined, isLoading: false }
|
||||
})
|
||||
|
||||
renderHook(() => useSnippetInit('snippet-1'))
|
||||
|
||||
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({
|
||||
nodesDefaultConfigs: {
|
||||
llm: { model: 'gpt-4.1' },
|
||||
code: { language: 'python3' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep object default block configs as-is', () => {
|
||||
mockUseSnippetDefaultBlockConfigs.mockImplementation((_snippetId: string, onSuccess?: (data: unknown) => void) => {
|
||||
onSuccess?.({
|
||||
llm: { model: 'gpt-4.1' },
|
||||
})
|
||||
return { data: undefined, isLoading: false }
|
||||
})
|
||||
|
||||
renderHook(() => useSnippetInit('snippet-1'))
|
||||
|
||||
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({
|
||||
nodesDefaultConfigs: {
|
||||
llm: { model: 'gpt-4.1' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should sync published created_at into workflow store', () => {
|
||||
mockUseSnippetPublishedWorkflow.mockImplementation((_snippetId: string, onSuccess?: (data: { created_at: number }) => void) => {
|
||||
onSuccess?.({
|
||||
created_at: 1_712_345_678,
|
||||
})
|
||||
return { data: undefined, isLoading: false }
|
||||
})
|
||||
|
||||
renderHook(() => useSnippetInit('snippet-1'))
|
||||
|
||||
expect(mockSetPublishedAt).toHaveBeenCalledWith(1_712_345_678)
|
||||
})
|
||||
|
||||
it('should stay loading while draft workflow is still fetching', () => {
|
||||
mockUseSnippetDraftWorkflow.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useSnippetInit('snippet-1'))
|
||||
|
||||
expect(result.current.data).toBeUndefined()
|
||||
expect(result.current.isLoading).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import { useSnippetStartRun } from '../use-snippet-start-run'
|
||||
|
||||
const mockWorkflowStoreGetState = vi.fn()
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: mockWorkflowStoreGetState,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockHandleCancelDebugAndPreviewPanel = vi.fn()
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useWorkflowInteractions: () => ({
|
||||
handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockSetShowDebugAndPreviewPanel = vi.fn()
|
||||
const mockSetShowInputsPanel = vi.fn()
|
||||
const mockSetShowEnvPanel = vi.fn()
|
||||
const mockSetShowGlobalVariablePanel = vi.fn()
|
||||
const mockHandleRun = vi.fn()
|
||||
|
||||
const inputFields: SnippetInputField[] = [
|
||||
{
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'Query',
|
||||
variable: 'query',
|
||||
required: true,
|
||||
},
|
||||
]
|
||||
|
||||
describe('useSnippetStartRun', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkflowStoreGetState.mockReturnValue({
|
||||
workflowRunningData: undefined,
|
||||
showDebugAndPreviewPanel: false,
|
||||
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
|
||||
setShowInputsPanel: mockSetShowInputsPanel,
|
||||
setShowEnvPanel: mockSetShowEnvPanel,
|
||||
setShowGlobalVariablePanel: mockSetShowGlobalVariablePanel,
|
||||
})
|
||||
})
|
||||
|
||||
it('should open the debug panel and input form when snippet has input fields', () => {
|
||||
const { result } = renderHook(() => useSnippetStartRun({
|
||||
handleRun: mockHandleRun,
|
||||
inputFields,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowStartRunInWorkflow()
|
||||
})
|
||||
|
||||
expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
|
||||
expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false)
|
||||
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
|
||||
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(true)
|
||||
expect(mockHandleRun).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should run immediately when snippet has no input fields', () => {
|
||||
const { result } = renderHook(() => useSnippetStartRun({
|
||||
handleRun: mockHandleRun,
|
||||
inputFields: [],
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowStartRunInWorkflow()
|
||||
})
|
||||
|
||||
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
|
||||
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(false)
|
||||
expect(mockHandleRun).toHaveBeenCalledWith({ inputs: {} })
|
||||
})
|
||||
|
||||
it('should close the panel when debug panel is already open', () => {
|
||||
mockWorkflowStoreGetState.mockReturnValue({
|
||||
workflowRunningData: undefined,
|
||||
showDebugAndPreviewPanel: true,
|
||||
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
|
||||
setShowInputsPanel: mockSetShowInputsPanel,
|
||||
setShowEnvPanel: mockSetShowEnvPanel,
|
||||
setShowGlobalVariablePanel: mockSetShowGlobalVariablePanel,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useSnippetStartRun({
|
||||
handleRun: mockHandleRun,
|
||||
inputFields,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowStartRunInWorkflow()
|
||||
})
|
||||
|
||||
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should do nothing when workflow is already running', () => {
|
||||
mockWorkflowStoreGetState.mockReturnValue({
|
||||
workflowRunningData: {
|
||||
result: {
|
||||
status: WorkflowRunningStatus.Running,
|
||||
},
|
||||
},
|
||||
showDebugAndPreviewPanel: false,
|
||||
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
|
||||
setShowInputsPanel: mockSetShowInputsPanel,
|
||||
setShowEnvPanel: mockSetShowEnvPanel,
|
||||
setShowGlobalVariablePanel: mockSetShowGlobalVariablePanel,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useSnippetStartRun({
|
||||
handleRun: mockHandleRun,
|
||||
inputFields,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowStartRunInWorkflow()
|
||||
})
|
||||
|
||||
expect(mockSetShowDebugAndPreviewPanel).not.toHaveBeenCalled()
|
||||
expect(mockHandleRun).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
24
web/app/components/snippets/hooks/use-configs-map.ts
Normal file
24
web/app/components/snippets/hooks/use-configs-map.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { Resolution, TransferMethod } from '@/types/app'
|
||||
import { FlowType } from '@/types/common'
|
||||
|
||||
export const useConfigsMap = (snippetId: string) => {
|
||||
const fileUploadConfig = useStore(s => s.fileUploadConfig)
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
flowId: snippetId,
|
||||
flowType: FlowType.snippet,
|
||||
fileSettings: {
|
||||
image: {
|
||||
enabled: false,
|
||||
detail: Resolution.high,
|
||||
number_limits: 3,
|
||||
transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url],
|
||||
},
|
||||
fileUploadConfig,
|
||||
},
|
||||
}
|
||||
}, [fileUploadConfig, snippetId])
|
||||
}
|
||||
13
web/app/components/snippets/hooks/use-inspect-vars-crud.ts
Normal file
13
web/app/components/snippets/hooks/use-inspect-vars-crud.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useInspectVarsCrudCommon } from '../../workflow/hooks/use-inspect-vars-crud-common'
|
||||
import { useConfigsMap } from './use-configs-map'
|
||||
|
||||
export const useInspectVarsCrud = (snippetId: string) => {
|
||||
const configsMap = useConfigsMap(snippetId)
|
||||
const apis = useInspectVarsCrudCommon({
|
||||
...configsMap,
|
||||
})
|
||||
|
||||
return {
|
||||
...apis,
|
||||
}
|
||||
}
|
||||
166
web/app/components/snippets/hooks/use-nodes-sync-draft.ts
Normal file
166
web/app/components/snippets/hooks/use-nodes-sync-draft.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import type { SyncDraftCallback } from '@/app/components/workflow/hooks-store'
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import type { SnippetDraftSyncPayload, SnippetWorkflow } from '@/types/snippet'
|
||||
import { produce } from 'immer'
|
||||
import { useCallback } from 'react'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import { useSerialAsyncCallback } from '@/app/components/workflow/hooks/use-serial-async-callback'
|
||||
import { useNodesReadOnly } from '@/app/components/workflow/hooks/use-workflow'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { API_PREFIX } from '@/config'
|
||||
import { consoleClient } from '@/service/client'
|
||||
import { postWithKeepalive } from '@/service/fetch'
|
||||
import { useSnippetRefreshDraft } from './use-snippet-refresh-draft'
|
||||
|
||||
const isSyncConflictError = (error: unknown): error is { bodyUsed: boolean, json: () => Promise<{ code?: string }> } => {
|
||||
return !!error
|
||||
&& typeof error === 'object'
|
||||
&& 'bodyUsed' in error
|
||||
&& 'json' in error
|
||||
&& typeof error.json === 'function'
|
||||
}
|
||||
|
||||
type SyncInputFieldsDraftCallback = SyncDraftCallback & {
|
||||
onRefresh?: (inputFields: SnippetInputField[]) => void
|
||||
}
|
||||
|
||||
export const useNodesSyncDraft = (snippetId: string) => {
|
||||
const store = useStoreApi()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { getNodesReadOnly } = useNodesReadOnly()
|
||||
const { handleRefreshWorkflowDraft } = useSnippetRefreshDraft(snippetId)
|
||||
|
||||
const getGraphSyncPayload = useCallback(() => {
|
||||
const {
|
||||
getNodes,
|
||||
edges,
|
||||
transform,
|
||||
} = store.getState()
|
||||
const nodes = getNodes().filter(node => !node.data?._isTempNode)
|
||||
const [x, y, zoom] = transform
|
||||
if (!snippetId)
|
||||
return null
|
||||
|
||||
const producedNodes = produce(nodes, (draft) => {
|
||||
draft.forEach((node) => {
|
||||
Object.keys(node.data).forEach((key) => {
|
||||
if (key.startsWith('_'))
|
||||
delete node.data[key]
|
||||
})
|
||||
})
|
||||
})
|
||||
const producedEdges = produce(edges.filter(edge => !edge.data?._isTemp), (draft) => {
|
||||
draft.forEach((edge) => {
|
||||
Object.keys(edge.data).forEach((key) => {
|
||||
if (key.startsWith('_'))
|
||||
delete edge.data[key]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
graph: {
|
||||
nodes: producedNodes,
|
||||
edges: producedEdges,
|
||||
viewport: { x, y, zoom },
|
||||
},
|
||||
}
|
||||
}, [snippetId, store])
|
||||
|
||||
const syncDraft = useCallback(async (
|
||||
payload: Omit<SnippetDraftSyncPayload, 'hash'>,
|
||||
notRefreshWhenSyncError?: boolean,
|
||||
callback?: SyncDraftCallback,
|
||||
onRefresh?: (draftWorkflow: SnippetWorkflow) => void,
|
||||
) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
if (!snippetId)
|
||||
return
|
||||
|
||||
const {
|
||||
setDraftUpdatedAt,
|
||||
setSyncWorkflowDraftHash,
|
||||
syncWorkflowDraftHash,
|
||||
} = workflowStore.getState()
|
||||
|
||||
try {
|
||||
const response = await consoleClient.snippets.syncDraftWorkflow({
|
||||
params: { snippetId },
|
||||
body: {
|
||||
...payload,
|
||||
hash: syncWorkflowDraftHash || undefined,
|
||||
},
|
||||
})
|
||||
|
||||
setSyncWorkflowDraftHash(response.hash)
|
||||
setDraftUpdatedAt(response.updated_at)
|
||||
callback?.onSuccess?.()
|
||||
}
|
||||
catch (error: unknown) {
|
||||
if (isSyncConflictError(error) && !error.bodyUsed) {
|
||||
error.json().then((err) => {
|
||||
if (err.code === 'draft_workflow_not_sync' && !notRefreshWhenSyncError)
|
||||
handleRefreshWorkflowDraft(onRefresh)
|
||||
})
|
||||
}
|
||||
callback?.onError?.()
|
||||
}
|
||||
finally {
|
||||
callback?.onSettled?.()
|
||||
}
|
||||
}, [getNodesReadOnly, handleRefreshWorkflowDraft, snippetId, workflowStore])
|
||||
|
||||
const syncWorkflowDraftWhenPageClose = useCallback(() => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const graphPayload = getGraphSyncPayload()
|
||||
if (!graphPayload)
|
||||
return
|
||||
|
||||
const { syncWorkflowDraftHash } = workflowStore.getState()
|
||||
postWithKeepalive(`${API_PREFIX}/snippets/${snippetId}/workflows/draft`, {
|
||||
...graphPayload,
|
||||
hash: syncWorkflowDraftHash,
|
||||
})
|
||||
}, [getGraphSyncPayload, getNodesReadOnly, snippetId, workflowStore])
|
||||
|
||||
const performSync = useCallback(async (
|
||||
notRefreshWhenSyncError?: boolean,
|
||||
callback?: SyncDraftCallback,
|
||||
) => {
|
||||
const graphPayload = getGraphSyncPayload()
|
||||
if (!graphPayload)
|
||||
return
|
||||
|
||||
await syncDraft(graphPayload, notRefreshWhenSyncError, callback)
|
||||
}, [getGraphSyncPayload, syncDraft])
|
||||
|
||||
const performInputFieldsSync = useCallback(async (
|
||||
inputFields: SnippetInputField[],
|
||||
callback?: SyncInputFieldsDraftCallback,
|
||||
) => {
|
||||
await syncDraft(
|
||||
{ input_fields: inputFields },
|
||||
false,
|
||||
callback,
|
||||
(draftWorkflow) => {
|
||||
const refreshedInputFields = Array.isArray(draftWorkflow.input_fields)
|
||||
? draftWorkflow.input_fields as SnippetInputField[]
|
||||
: []
|
||||
callback?.onRefresh?.(refreshedInputFields)
|
||||
},
|
||||
)
|
||||
}, [syncDraft])
|
||||
|
||||
const doSyncWorkflowDraft = useSerialAsyncCallback(performSync, getNodesReadOnly)
|
||||
const syncInputFieldsDraft = useSerialAsyncCallback(performInputFieldsSync)
|
||||
|
||||
return {
|
||||
doSyncWorkflowDraft,
|
||||
syncInputFieldsDraft,
|
||||
syncWorkflowDraftWhenPageClose,
|
||||
}
|
||||
}
|
||||
82
web/app/components/snippets/hooks/use-snippet-init.ts
Normal file
82
web/app/components/snippets/hooks/use-snippet-init.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import {
|
||||
useSnippetDefaultBlockConfigs,
|
||||
useSnippetDraftWorkflow,
|
||||
useSnippetPublishedWorkflow,
|
||||
} from '@/service/use-snippet-workflows'
|
||||
import {
|
||||
buildSnippetDetailPayload,
|
||||
useSnippetApiDetail,
|
||||
} from '@/service/use-snippets'
|
||||
import { getSnippetDetailMock } from '@/service/use-snippets.mock'
|
||||
|
||||
const normalizeNodesDefaultConfigs = (nodesDefaultConfigs: unknown) => {
|
||||
if (!nodesDefaultConfigs || typeof nodesDefaultConfigs !== 'object')
|
||||
return {}
|
||||
|
||||
if (!Array.isArray(nodesDefaultConfigs))
|
||||
return nodesDefaultConfigs as Record<string, unknown>
|
||||
|
||||
return nodesDefaultConfigs.reduce((acc, item) => {
|
||||
if (
|
||||
item
|
||||
&& typeof item === 'object'
|
||||
&& 'type' in item
|
||||
&& 'config' in item
|
||||
&& typeof item.type === 'string'
|
||||
) {
|
||||
acc[item.type] = item.config
|
||||
}
|
||||
|
||||
return acc
|
||||
}, {} as Record<string, unknown>)
|
||||
}
|
||||
|
||||
const isNotFoundError = (error: unknown) => {
|
||||
return !!error && typeof error === 'object' && 'status' in error && error.status === 404
|
||||
}
|
||||
|
||||
export const useSnippetInit = (snippetId: string) => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const snippetApiDetail = useSnippetApiDetail(snippetId)
|
||||
const draftWorkflowQuery = useSnippetDraftWorkflow(snippetId, (draftWorkflow) => {
|
||||
const {
|
||||
setDraftUpdatedAt,
|
||||
setSyncWorkflowDraftHash,
|
||||
} = workflowStore.getState()
|
||||
|
||||
setDraftUpdatedAt(draftWorkflow.updated_at)
|
||||
setSyncWorkflowDraftHash(draftWorkflow.hash)
|
||||
})
|
||||
useSnippetDefaultBlockConfigs(snippetId, (nodesDefaultConfigs) => {
|
||||
workflowStore.setState({
|
||||
nodesDefaultConfigs: normalizeNodesDefaultConfigs(nodesDefaultConfigs),
|
||||
})
|
||||
})
|
||||
useSnippetPublishedWorkflow(snippetId, (publishedWorkflow) => {
|
||||
workflowStore.getState().setPublishedAt(publishedWorkflow.created_at)
|
||||
})
|
||||
|
||||
const mockData = useMemo(() => getSnippetDetailMock(snippetId), [snippetId])
|
||||
const shouldUseMockData = !snippetApiDetail.isLoading && !snippetApiDetail.data && !!mockData
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (snippetApiDetail.data && !draftWorkflowQuery.isLoading)
|
||||
return buildSnippetDetailPayload(snippetApiDetail.data, draftWorkflowQuery.data)
|
||||
|
||||
if (shouldUseMockData)
|
||||
return mockData
|
||||
|
||||
if (snippetApiDetail.error && isNotFoundError(snippetApiDetail.error))
|
||||
return null
|
||||
|
||||
return undefined
|
||||
}, [draftWorkflowQuery.data, draftWorkflowQuery.isLoading, mockData, shouldUseMockData, snippetApiDetail.data, snippetApiDetail.error])
|
||||
|
||||
return {
|
||||
...snippetApiDetail,
|
||||
data,
|
||||
isLoading: shouldUseMockData ? false : snippetApiDetail.isLoading || draftWorkflowQuery.isLoading,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { WorkflowDataUpdater } from '@/app/components/workflow/types'
|
||||
import type { SnippetWorkflow } from '@/types/snippet'
|
||||
import { useCallback } from 'react'
|
||||
import { useWorkflowUpdate } from '@/app/components/workflow/hooks'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { consoleClient } from '@/service/client'
|
||||
|
||||
export const useSnippetRefreshDraft = (snippetId: string) => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { handleUpdateWorkflowCanvas } = useWorkflowUpdate()
|
||||
|
||||
const handleRefreshWorkflowDraft = useCallback((onSuccess?: (draftWorkflow: SnippetWorkflow) => void) => {
|
||||
const {
|
||||
setDraftUpdatedAt,
|
||||
setIsSyncingWorkflowDraft,
|
||||
setSyncWorkflowDraftHash,
|
||||
} = workflowStore.getState()
|
||||
|
||||
if (!snippetId)
|
||||
return
|
||||
|
||||
setIsSyncingWorkflowDraft(true)
|
||||
consoleClient.snippets.draftWorkflow({
|
||||
params: { snippetId },
|
||||
}).then((response) => {
|
||||
handleUpdateWorkflowCanvas({
|
||||
...response.graph,
|
||||
nodes: response.graph?.nodes || [],
|
||||
edges: response.graph?.edges || [],
|
||||
viewport: response.graph?.viewport || { x: 0, y: 0, zoom: 1 },
|
||||
} as WorkflowDataUpdater)
|
||||
setSyncWorkflowDraftHash(response.hash)
|
||||
setDraftUpdatedAt(response.updated_at)
|
||||
onSuccess?.(response)
|
||||
}).finally(() => {
|
||||
setIsSyncingWorkflowDraft(false)
|
||||
})
|
||||
}, [handleUpdateWorkflowCanvas, snippetId, workflowStore])
|
||||
|
||||
return {
|
||||
handleRefreshWorkflowDraft,
|
||||
}
|
||||
}
|
||||
298
web/app/components/snippets/hooks/use-snippet-run.ts
Normal file
298
web/app/components/snippets/hooks/use-snippet-run.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import type { IOtherOptions } from '@/service/base'
|
||||
import type { SnippetDraftRunPayload } from '@/types/snippet'
|
||||
import type { VersionHistory } from '@/types/workflow'
|
||||
import { produce } from 'immer'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import {
|
||||
useReactFlow,
|
||||
useStoreApi,
|
||||
} from 'reactflow'
|
||||
import { useSetWorkflowVarsWithValue } from '@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars'
|
||||
import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-interactions'
|
||||
import { useWorkflowRunEvent } from '@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { ssePost } from '@/service/base'
|
||||
import { useInvalidAllLastRun, useInvalidateWorkflowRunHistory } from '@/service/use-workflow'
|
||||
import { stopWorkflowRun } from '@/service/workflow'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { useNodesSyncDraft } from './use-nodes-sync-draft'
|
||||
|
||||
export const useSnippetRun = (snippetId: string) => {
|
||||
const store = useStoreApi()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const reactflow = useReactFlow()
|
||||
const { doSyncWorkflowDraft } = useNodesSyncDraft(snippetId)
|
||||
const { handleUpdateWorkflowCanvas } = useWorkflowUpdate()
|
||||
|
||||
const {
|
||||
handleWorkflowStarted,
|
||||
handleWorkflowFinished,
|
||||
handleWorkflowFailed,
|
||||
handleWorkflowNodeStarted,
|
||||
handleWorkflowNodeFinished,
|
||||
handleWorkflowNodeIterationStarted,
|
||||
handleWorkflowNodeIterationNext,
|
||||
handleWorkflowNodeIterationFinished,
|
||||
handleWorkflowNodeLoopStarted,
|
||||
handleWorkflowNodeLoopNext,
|
||||
handleWorkflowNodeLoopFinished,
|
||||
handleWorkflowNodeRetry,
|
||||
handleWorkflowAgentLog,
|
||||
handleWorkflowTextChunk,
|
||||
handleWorkflowTextReplace,
|
||||
} = useWorkflowRunEvent()
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
const handleBackupDraft = useCallback(() => {
|
||||
const {
|
||||
getNodes,
|
||||
edges,
|
||||
} = store.getState()
|
||||
const { getViewport } = reactflow
|
||||
const {
|
||||
backupDraft,
|
||||
setBackupDraft,
|
||||
} = workflowStore.getState()
|
||||
|
||||
if (!backupDraft) {
|
||||
setBackupDraft({
|
||||
nodes: getNodes(),
|
||||
edges,
|
||||
viewport: getViewport(),
|
||||
environmentVariables: [],
|
||||
})
|
||||
doSyncWorkflowDraft()
|
||||
}
|
||||
}, [doSyncWorkflowDraft, reactflow, store, workflowStore])
|
||||
|
||||
const handleLoadBackupDraft = useCallback(() => {
|
||||
const {
|
||||
backupDraft,
|
||||
setBackupDraft,
|
||||
setEnvironmentVariables,
|
||||
} = workflowStore.getState()
|
||||
|
||||
if (backupDraft) {
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
viewport,
|
||||
} = backupDraft
|
||||
handleUpdateWorkflowCanvas({
|
||||
nodes,
|
||||
edges,
|
||||
viewport,
|
||||
})
|
||||
setEnvironmentVariables([])
|
||||
setBackupDraft(undefined)
|
||||
}
|
||||
}, [handleUpdateWorkflowCanvas, workflowStore])
|
||||
|
||||
const invalidAllLastRun = useInvalidAllLastRun(FlowType.snippet, snippetId)
|
||||
const invalidateRunHistory = useInvalidateWorkflowRunHistory()
|
||||
const { fetchInspectVars } = useSetWorkflowVarsWithValue({
|
||||
flowType: FlowType.snippet,
|
||||
flowId: snippetId,
|
||||
})
|
||||
|
||||
const handleRun = useCallback(async (
|
||||
params: SnippetDraftRunPayload,
|
||||
callback?: IOtherOptions,
|
||||
) => {
|
||||
const {
|
||||
getNodes,
|
||||
setNodes,
|
||||
} = store.getState()
|
||||
const newNodes = produce(getNodes(), (draft) => {
|
||||
draft.forEach((node) => {
|
||||
node.data.selected = false
|
||||
node.data._runningStatus = undefined
|
||||
})
|
||||
})
|
||||
setNodes(newNodes)
|
||||
await doSyncWorkflowDraft()
|
||||
|
||||
const {
|
||||
onWorkflowStarted,
|
||||
onWorkflowFinished,
|
||||
onNodeStarted,
|
||||
onNodeFinished,
|
||||
onIterationStart,
|
||||
onIterationNext,
|
||||
onIterationFinish,
|
||||
onLoopStart,
|
||||
onLoopNext,
|
||||
onLoopFinish,
|
||||
onNodeRetry,
|
||||
onAgentLog,
|
||||
onError,
|
||||
...restCallback
|
||||
} = callback || {}
|
||||
const runHistoryUrl = `/snippets/${snippetId}/workflow-runs`
|
||||
workflowStore.setState({ historyWorkflowData: undefined })
|
||||
const workflowContainer = document.getElementById('workflow-container')
|
||||
|
||||
const {
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
} = workflowContainer!
|
||||
|
||||
const url = `/snippets/${snippetId}/workflows/draft/run`
|
||||
|
||||
const {
|
||||
setWorkflowRunningData,
|
||||
} = workflowStore.getState()
|
||||
setWorkflowRunningData({
|
||||
result: {
|
||||
inputs_truncated: false,
|
||||
process_data_truncated: false,
|
||||
outputs_truncated: false,
|
||||
status: WorkflowRunningStatus.Running,
|
||||
},
|
||||
tracing: [],
|
||||
resultText: '',
|
||||
})
|
||||
|
||||
abortControllerRef.current?.abort()
|
||||
abortControllerRef.current = null
|
||||
|
||||
ssePost(
|
||||
url,
|
||||
{
|
||||
body: params,
|
||||
},
|
||||
{
|
||||
getAbortController: (controller: AbortController) => {
|
||||
abortControllerRef.current = controller
|
||||
},
|
||||
onWorkflowStarted: (params) => {
|
||||
handleWorkflowStarted(params)
|
||||
invalidateRunHistory(runHistoryUrl)
|
||||
|
||||
onWorkflowStarted?.(params)
|
||||
},
|
||||
onWorkflowFinished: (params) => {
|
||||
handleWorkflowFinished(params)
|
||||
invalidateRunHistory(runHistoryUrl)
|
||||
fetchInspectVars({})
|
||||
invalidAllLastRun()
|
||||
|
||||
onWorkflowFinished?.(params)
|
||||
},
|
||||
onError: (params) => {
|
||||
handleWorkflowFailed()
|
||||
invalidateRunHistory(runHistoryUrl)
|
||||
|
||||
onError?.(params)
|
||||
},
|
||||
onNodeStarted: (params) => {
|
||||
handleWorkflowNodeStarted(
|
||||
params,
|
||||
{
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
},
|
||||
)
|
||||
|
||||
onNodeStarted?.(params)
|
||||
},
|
||||
onNodeFinished: (params) => {
|
||||
handleWorkflowNodeFinished(params)
|
||||
|
||||
onNodeFinished?.(params)
|
||||
},
|
||||
onIterationStart: (params) => {
|
||||
handleWorkflowNodeIterationStarted(
|
||||
params,
|
||||
{
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
},
|
||||
)
|
||||
|
||||
onIterationStart?.(params)
|
||||
},
|
||||
onIterationNext: (params) => {
|
||||
handleWorkflowNodeIterationNext(params)
|
||||
|
||||
onIterationNext?.(params)
|
||||
},
|
||||
onIterationFinish: (params) => {
|
||||
handleWorkflowNodeIterationFinished(params)
|
||||
|
||||
onIterationFinish?.(params)
|
||||
},
|
||||
onLoopStart: (params) => {
|
||||
handleWorkflowNodeLoopStarted(
|
||||
params,
|
||||
{
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
},
|
||||
)
|
||||
|
||||
onLoopStart?.(params)
|
||||
},
|
||||
onLoopNext: (params) => {
|
||||
handleWorkflowNodeLoopNext(params)
|
||||
|
||||
onLoopNext?.(params)
|
||||
},
|
||||
onLoopFinish: (params) => {
|
||||
handleWorkflowNodeLoopFinished(params)
|
||||
|
||||
onLoopFinish?.(params)
|
||||
},
|
||||
onNodeRetry: (params) => {
|
||||
handleWorkflowNodeRetry(params)
|
||||
|
||||
onNodeRetry?.(params)
|
||||
},
|
||||
onAgentLog: (params) => {
|
||||
handleWorkflowAgentLog(params)
|
||||
|
||||
onAgentLog?.(params)
|
||||
},
|
||||
onTextChunk: (params) => {
|
||||
handleWorkflowTextChunk(params)
|
||||
},
|
||||
onTextReplace: (params) => {
|
||||
handleWorkflowTextReplace(params)
|
||||
},
|
||||
...restCallback,
|
||||
},
|
||||
)
|
||||
}, [doSyncWorkflowDraft, fetchInspectVars, handleWorkflowAgentLog, handleWorkflowFailed, handleWorkflowFinished, handleWorkflowNodeFinished, handleWorkflowNodeIterationFinished, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationStarted, handleWorkflowNodeLoopFinished, handleWorkflowNodeLoopNext, handleWorkflowNodeLoopStarted, handleWorkflowNodeRetry, handleWorkflowNodeStarted, handleWorkflowStarted, handleWorkflowTextChunk, handleWorkflowTextReplace, invalidAllLastRun, invalidateRunHistory, snippetId, store, workflowStore])
|
||||
|
||||
const handleStopRun = useCallback((taskId: string) => {
|
||||
stopWorkflowRun(`/snippets/${snippetId}/workflow-runs/tasks/${taskId}/stop`)
|
||||
|
||||
if (abortControllerRef.current)
|
||||
abortControllerRef.current.abort()
|
||||
|
||||
abortControllerRef.current = null
|
||||
}, [snippetId])
|
||||
|
||||
const handleRestoreFromPublishedWorkflow = useCallback((publishedWorkflow: VersionHistory) => {
|
||||
const nodes = publishedWorkflow.graph.nodes.map(node => ({ ...node, selected: false, data: { ...node.data, selected: false } }))
|
||||
const edges = publishedWorkflow.graph.edges
|
||||
const viewport = publishedWorkflow.graph.viewport!
|
||||
handleUpdateWorkflowCanvas({
|
||||
nodes,
|
||||
edges,
|
||||
viewport,
|
||||
})
|
||||
|
||||
workflowStore.getState().setEnvironmentVariables([])
|
||||
}, [handleUpdateWorkflowCanvas, workflowStore])
|
||||
|
||||
return {
|
||||
handleBackupDraft,
|
||||
handleLoadBackupDraft,
|
||||
handleRun,
|
||||
handleStopRun,
|
||||
handleRestoreFromPublishedWorkflow,
|
||||
}
|
||||
}
|
||||
60
web/app/components/snippets/hooks/use-snippet-start-run.ts
Normal file
60
web/app/components/snippets/hooks/use-snippet-start-run.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import type { SnippetDraftRunPayload } from '@/types/snippet'
|
||||
import { useCallback } from 'react'
|
||||
import { useWorkflowInteractions } from '@/app/components/workflow/hooks'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
|
||||
type UseSnippetStartRunOptions = {
|
||||
handleRun: (params: SnippetDraftRunPayload) => void
|
||||
inputFields: SnippetInputField[]
|
||||
}
|
||||
|
||||
export const useSnippetStartRun = ({
|
||||
handleRun,
|
||||
inputFields,
|
||||
}: UseSnippetStartRunOptions) => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions()
|
||||
|
||||
const handleWorkflowStartRunInWorkflow = useCallback(() => {
|
||||
const {
|
||||
workflowRunningData,
|
||||
showDebugAndPreviewPanel,
|
||||
setShowDebugAndPreviewPanel,
|
||||
setShowInputsPanel,
|
||||
setShowEnvPanel,
|
||||
setShowGlobalVariablePanel,
|
||||
} = workflowStore.getState()
|
||||
|
||||
if (workflowRunningData?.result.status === WorkflowRunningStatus.Running)
|
||||
return
|
||||
|
||||
setShowEnvPanel(false)
|
||||
setShowGlobalVariablePanel(false)
|
||||
|
||||
if (showDebugAndPreviewPanel) {
|
||||
handleCancelDebugAndPreviewPanel()
|
||||
return
|
||||
}
|
||||
|
||||
setShowDebugAndPreviewPanel(true)
|
||||
|
||||
if (inputFields.length > 0) {
|
||||
setShowInputsPanel(true)
|
||||
return
|
||||
}
|
||||
|
||||
setShowInputsPanel(false)
|
||||
handleRun({ inputs: {} })
|
||||
}, [handleCancelDebugAndPreviewPanel, handleRun, inputFields.length, workflowStore])
|
||||
|
||||
const handleStartWorkflowRun = useCallback(() => {
|
||||
handleWorkflowStartRunInWorkflow()
|
||||
}, [handleWorkflowStartRunInWorkflow])
|
||||
|
||||
return {
|
||||
handleStartWorkflowRun,
|
||||
handleWorkflowStartRunInWorkflow,
|
||||
}
|
||||
}
|
||||
77
web/app/components/snippets/index.tsx
Normal file
77
web/app/components/snippets/index.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import WorkflowWithDefaultContext from '@/app/components/workflow'
|
||||
import { WorkflowContextProvider } from '@/app/components/workflow/context'
|
||||
import {
|
||||
initialEdges,
|
||||
initialNodes,
|
||||
} from '@/app/components/workflow/utils'
|
||||
import SnippetLayout from './components/snippet-layout'
|
||||
import SnippetMain from './components/snippet-main'
|
||||
import { useSnippetInit } from './hooks/use-snippet-init'
|
||||
|
||||
type SnippetPageProps = {
|
||||
snippetId: string
|
||||
}
|
||||
|
||||
const SnippetPageLoading = () => {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-background-body">
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SnippetPage = ({ snippetId }: SnippetPageProps) => {
|
||||
const { data, isLoading } = useSnippetInit(snippetId)
|
||||
const nodesData = useMemo(() => {
|
||||
if (!data)
|
||||
return []
|
||||
|
||||
return initialNodes(data.graph.nodes, data.graph.edges)
|
||||
}, [data])
|
||||
const edgesData = useMemo(() => {
|
||||
if (!data)
|
||||
return []
|
||||
|
||||
return initialEdges(data.graph.edges, data.graph.nodes)
|
||||
}, [data])
|
||||
|
||||
if (!data || isLoading) {
|
||||
return <SnippetPageLoading />
|
||||
}
|
||||
|
||||
return (
|
||||
<SnippetLayout
|
||||
snippetId={snippetId}
|
||||
snippet={data.snippet}
|
||||
section="orchestrate"
|
||||
>
|
||||
<WorkflowWithDefaultContext
|
||||
edges={edgesData}
|
||||
nodes={nodesData}
|
||||
>
|
||||
<SnippetMain
|
||||
key={snippetId}
|
||||
snippetId={snippetId}
|
||||
payload={data}
|
||||
nodes={nodesData}
|
||||
edges={edgesData}
|
||||
viewport={data.graph.viewport}
|
||||
/>
|
||||
</WorkflowWithDefaultContext>
|
||||
</SnippetLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const SnippetPageWrapper = ({ snippetId }: SnippetPageProps) => {
|
||||
return (
|
||||
<WorkflowContextProvider>
|
||||
<SnippetPage snippetId={snippetId} />
|
||||
</WorkflowContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetPageWrapper
|
||||
30
web/app/components/snippets/snippet-evaluation-page.tsx
Normal file
30
web/app/components/snippets/snippet-evaluation-page.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import Evaluation from '@/app/components/evaluation'
|
||||
import { getSnippetDetailMock } from '@/service/use-snippets.mock'
|
||||
import SnippetLayout from './components/snippet-layout'
|
||||
|
||||
type SnippetEvaluationPageProps = {
|
||||
snippetId: string
|
||||
}
|
||||
|
||||
const SnippetEvaluationPage = ({ snippetId }: SnippetEvaluationPageProps) => {
|
||||
const mockSnippet = useMemo(() => getSnippetDetailMock(snippetId)?.snippet, [snippetId])
|
||||
const snippet = mockSnippet
|
||||
|
||||
if (!snippet)
|
||||
return null
|
||||
|
||||
return (
|
||||
<SnippetLayout
|
||||
snippetId={snippetId}
|
||||
snippet={snippet}
|
||||
section="evaluation"
|
||||
>
|
||||
<Evaluation resourceType="snippet" resourceId={snippetId} />
|
||||
</SnippetLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default SnippetEvaluationPage
|
||||
44
web/app/components/snippets/store/index.ts
Normal file
44
web/app/components/snippets/store/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import type { SnippetInputField, SnippetSection } from '@/models/snippet'
|
||||
import { create } from 'zustand'
|
||||
|
||||
type SnippetDetailUIState = {
|
||||
activeSection: SnippetSection
|
||||
isInputPanelOpen: boolean
|
||||
isPublishMenuOpen: boolean
|
||||
isPreviewMode: boolean
|
||||
isEditorOpen: boolean
|
||||
editingField: SnippetInputField | null
|
||||
setActiveSection: (section: SnippetSection) => void
|
||||
setInputPanelOpen: (value: boolean) => void
|
||||
toggleInputPanel: () => void
|
||||
setPublishMenuOpen: (value: boolean) => void
|
||||
togglePublishMenu: () => void
|
||||
setPreviewMode: (value: boolean) => void
|
||||
openEditor: (field?: SnippetInputField | null) => void
|
||||
closeEditor: () => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
activeSection: 'orchestrate' as SnippetSection,
|
||||
isInputPanelOpen: false,
|
||||
isPublishMenuOpen: false,
|
||||
isPreviewMode: false,
|
||||
editingField: null,
|
||||
isEditorOpen: false,
|
||||
}
|
||||
|
||||
export const useSnippetDetailStore = create<SnippetDetailUIState>(set => ({
|
||||
...initialState,
|
||||
setActiveSection: activeSection => set({ activeSection }),
|
||||
setInputPanelOpen: isInputPanelOpen => set({ isInputPanelOpen }),
|
||||
toggleInputPanel: () => set(state => ({ isInputPanelOpen: !state.isInputPanelOpen, isPublishMenuOpen: false })),
|
||||
setPublishMenuOpen: isPublishMenuOpen => set({ isPublishMenuOpen }),
|
||||
togglePublishMenu: () => set(state => ({ isPublishMenuOpen: !state.isPublishMenuOpen })),
|
||||
setPreviewMode: isPreviewMode => set({ isPreviewMode }),
|
||||
openEditor: (editingField = null) => set({ editingField, isEditorOpen: true, isInputPanelOpen: true }),
|
||||
closeEditor: () => set({ editingField: null, isEditorOpen: false }),
|
||||
reset: () => set(initialState),
|
||||
}))
|
||||
@@ -11,20 +11,53 @@ let latestNodes: Node[] = []
|
||||
let latestHistoryEvent: string | undefined
|
||||
const mockGetNodesReadOnly = vi.fn()
|
||||
const mockHandleNodesCopy = vi.fn()
|
||||
const mockHandleNodesDuplicate = vi.fn()
|
||||
const mockHandleNodesDelete = vi.fn()
|
||||
const mockHandleNodesDuplicate = vi.fn()
|
||||
const mockPush = vi.fn()
|
||||
const mockToastSuccess = vi.fn()
|
||||
const mockToastError = vi.fn()
|
||||
const mockCreateSnippetMutateAsync = vi.fn()
|
||||
const mockSyncDraftWorkflow = vi.fn()
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
success: (...args: unknown[]) => mockToastSuccess(...args),
|
||||
error: (...args: unknown[]) => mockToastError(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippets', () => ({
|
||||
useCreateSnippetMutation: () => ({
|
||||
mutateAsync: mockCreateSnippetMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleClient: {
|
||||
snippets: {
|
||||
syncDraftWorkflow: (...args: unknown[]) => mockSyncDraftWorkflow(...args),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('../hooks')>('../hooks')
|
||||
return {
|
||||
...actual,
|
||||
useNodesReadOnly: () => ({
|
||||
getNodesReadOnly: mockGetNodesReadOnly,
|
||||
}),
|
||||
useNodesInteractions: () => ({
|
||||
handleNodesCopy: mockHandleNodesCopy,
|
||||
handleNodesDuplicate: mockHandleNodesDuplicate,
|
||||
handleNodesDelete: mockHandleNodesDelete,
|
||||
handleNodesDuplicate: mockHandleNodesDuplicate,
|
||||
}),
|
||||
useNodesReadOnly: () => ({
|
||||
getNodesReadOnly: mockGetNodesReadOnly,
|
||||
}),
|
||||
}
|
||||
})
|
||||
@@ -82,8 +115,13 @@ describe('SelectionContextmenu', () => {
|
||||
mockGetNodesReadOnly.mockReset()
|
||||
mockGetNodesReadOnly.mockReturnValue(false)
|
||||
mockHandleNodesCopy.mockReset()
|
||||
mockHandleNodesDuplicate.mockReset()
|
||||
mockHandleNodesDelete.mockReset()
|
||||
mockHandleNodesDuplicate.mockReset()
|
||||
mockPush.mockReset()
|
||||
mockToastSuccess.mockReset()
|
||||
mockToastError.mockReset()
|
||||
mockCreateSnippetMutateAsync.mockReset()
|
||||
mockSyncDraftWorkflow.mockReset()
|
||||
})
|
||||
|
||||
it('should not render when selectionMenu is absent', () => {
|
||||
@@ -98,6 +136,19 @@ describe('SelectionContextmenu', () => {
|
||||
createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }),
|
||||
]
|
||||
const { store } = renderSelectionMenu({ nodes })
|
||||
const container = document.querySelector('#workflow-container') as HTMLDivElement
|
||||
|
||||
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 16,
|
||||
y: 24,
|
||||
left: 16,
|
||||
top: 24,
|
||||
right: 816,
|
||||
bottom: 624,
|
||||
width: 800,
|
||||
height: 600,
|
||||
toJSON: () => ({}),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
store.setState({ selectionMenu: { clientX: 780, clientY: 590 } })
|
||||
@@ -196,6 +247,107 @@ describe('SelectionContextmenu', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should render selection actions and delegate copy, duplicate, and delete', () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'n1', selected: true, width: 40, height: 20 }),
|
||||
createNode({ id: 'n2', selected: true, position: { x: 80, y: 20 }, width: 40, height: 20 }),
|
||||
]
|
||||
|
||||
const { store } = renderSelectionMenu({ nodes })
|
||||
|
||||
act(() => {
|
||||
store.setState({ selectionMenu: { clientX: 120, clientY: 120 } })
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('selection-contextmenu-item-copy')).toHaveTextContent('workflow.common.copy')
|
||||
expect(screen.getByTestId('selection-contextmenu-item-duplicate')).toHaveTextContent('workflow.common.duplicate')
|
||||
expect(screen.getByTestId('selection-contextmenu-item-delete')).toHaveTextContent('common.operation.delete')
|
||||
|
||||
fireEvent.click(screen.getByTestId('selection-contextmenu-item-copy'))
|
||||
|
||||
act(() => {
|
||||
store.setState({ selectionMenu: { clientX: 120, clientY: 120 } })
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('selection-contextmenu-item-duplicate'))
|
||||
|
||||
act(() => {
|
||||
store.setState({ selectionMenu: { clientX: 120, clientY: 120 } })
|
||||
})
|
||||
fireEvent.click(screen.getByTestId('selection-contextmenu-item-delete'))
|
||||
|
||||
expect(mockHandleNodesCopy).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleNodesDuplicate).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleNodesDelete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should create a snippet with the selected graph and redirect to the snippet detail page', async () => {
|
||||
mockCreateSnippetMutateAsync.mockResolvedValue({ id: 'snippet-123' })
|
||||
mockSyncDraftWorkflow.mockResolvedValue({ result: 'success' })
|
||||
|
||||
const nodes = [
|
||||
createNode({ id: 'n1', selected: true, position: { x: 120, y: 60 }, width: 40, height: 20 }),
|
||||
createNode({ id: 'n2', selected: true, position: { x: 260, y: 120 }, width: 60, height: 30 }),
|
||||
createNode({ id: 'n3', selected: false, position: { x: 500, y: 300 }, width: 40, height: 20 }),
|
||||
]
|
||||
const edges = [
|
||||
createEdge({ id: 'e1', source: 'n1', target: 'n2' }),
|
||||
createEdge({ id: 'e2', source: 'n2', target: 'n3' }),
|
||||
]
|
||||
|
||||
const { store } = renderSelectionMenu({ nodes, edges })
|
||||
|
||||
act(() => {
|
||||
store.setState({ selectionMenu: { clientX: 120, clientY: 120 } })
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('selection-contextmenu-item-createSnippet'))
|
||||
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.namePlaceholder'), {
|
||||
target: { value: 'My snippet' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /workflow\.snippet\.confirm/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateSnippetMutateAsync).toHaveBeenCalledWith({
|
||||
body: expect.objectContaining({
|
||||
name: 'My snippet',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockSyncDraftWorkflow).toHaveBeenCalledWith({
|
||||
params: { snippetId: 'snippet-123' },
|
||||
body: {
|
||||
graph: {
|
||||
nodes: [
|
||||
expect.objectContaining({
|
||||
id: 'n1',
|
||||
position: { x: 0, y: 0 },
|
||||
selected: false,
|
||||
data: expect.objectContaining({ selected: false }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'n2',
|
||||
position: { x: 140, y: 60 },
|
||||
selected: false,
|
||||
data: expect.objectContaining({ selected: false }),
|
||||
}),
|
||||
],
|
||||
edges: [
|
||||
expect.objectContaining({
|
||||
id: 'e1',
|
||||
source: 'n1',
|
||||
target: 'n2',
|
||||
selected: false,
|
||||
}),
|
||||
],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith('workflow.snippet.createSuccess')
|
||||
expect(mockPush).toHaveBeenCalledWith('/snippets/snippet-123/orchestrate')
|
||||
})
|
||||
|
||||
it('should distribute selected nodes horizontally', async () => {
|
||||
const nodes = [
|
||||
createNode({ id: 'n1', selected: true, position: { x: 0, y: 10 }, width: 20, height: 20 }),
|
||||
|
||||
@@ -40,6 +40,7 @@ export const useTabs = ({
|
||||
noStart = true,
|
||||
defaultActiveTab,
|
||||
hasUserInputNode = false,
|
||||
disableStartTab = false,
|
||||
forceEnableStartTab = false, // When true, Start tab remains enabled even if trigger/user input nodes already exist.
|
||||
}: {
|
||||
noBlocks?: boolean
|
||||
@@ -48,11 +49,15 @@ export const useTabs = ({
|
||||
noStart?: boolean
|
||||
defaultActiveTab?: TabsEnum
|
||||
hasUserInputNode?: boolean
|
||||
disableStartTab?: boolean
|
||||
forceEnableStartTab?: boolean
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const shouldShowStartTab = !noStart
|
||||
const shouldDisableStartTab = !forceEnableStartTab && hasUserInputNode
|
||||
const shouldDisableStartTab = disableStartTab || (!forceEnableStartTab && hasUserInputNode)
|
||||
const startDisabledTip = disableStartTab
|
||||
? t('tabs.startNotSupportedTip', { ns: 'workflow' })
|
||||
: t('tabs.startDisabledTip', { ns: 'workflow' })
|
||||
const tabs = useMemo(() => {
|
||||
const tabConfigs = [{
|
||||
key: TabsEnum.Blocks,
|
||||
@@ -71,10 +76,15 @@ export const useTabs = ({
|
||||
name: t('tabs.start', { ns: 'workflow' }),
|
||||
show: shouldShowStartTab,
|
||||
disabled: shouldDisableStartTab,
|
||||
disabledTip: shouldDisableStartTab ? startDisabledTip : undefined,
|
||||
}, {
|
||||
key: TabsEnum.Snippets,
|
||||
name: t('tabs.snippets', { ns: 'workflow' }),
|
||||
show: true,
|
||||
}]
|
||||
|
||||
return tabConfigs.filter(tab => tab.show)
|
||||
}, [t, noBlocks, noSources, noTools, shouldShowStartTab, shouldDisableStartTab])
|
||||
}, [t, noBlocks, noSources, noTools, shouldShowStartTab, shouldDisableStartTab, startDisabledTip])
|
||||
|
||||
const getValidTabKey = useCallback((targetKey?: TabsEnum) => {
|
||||
if (!targetKey)
|
||||
@@ -100,6 +110,7 @@ export const useTabs = ({
|
||||
preferredOrder.push(TabsEnum.Sources)
|
||||
if (!noStart)
|
||||
preferredOrder.push(TabsEnum.Start)
|
||||
preferredOrder.push(TabsEnum.Snippets)
|
||||
|
||||
for (const tabKey of preferredOrder) {
|
||||
const validKey = getValidTabKey(tabKey)
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
@@ -29,9 +30,12 @@ import {
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import SearchBox from '@/app/components/plugins/marketplace/search-box'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { BlockEnum, isTriggerNode } from '../types'
|
||||
import { useTabs } from './hooks'
|
||||
import Snippets from './snippets'
|
||||
import Tabs from './tabs'
|
||||
import { TabsEnum } from './types'
|
||||
|
||||
@@ -87,7 +91,9 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const nodes = useNodes()
|
||||
const flowType = useHooksStore(s => s.configsMap?.flowType)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [snippetsLoading, setSnippetsLoading] = useState(() => Boolean(openFromProps) && defaultActiveTab === TabsEnum.Snippets)
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const [localOpen, setLocalOpen] = useState(false)
|
||||
// Exclude nodes explicitly ignored (such as the node currently being edited) when checking canvas state.
|
||||
@@ -119,16 +125,36 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
// Default rule: user input option is only available when no Start node nor Trigger node exists on canvas.
|
||||
const defaultAllowUserInputSelection = !hasUserInputNode && !hasTriggerNode
|
||||
const canSelectUserInput = allowUserInputSelection ?? defaultAllowUserInputSelection
|
||||
const disableStartTab = flowType === FlowType.snippet
|
||||
const {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
tabs,
|
||||
} = useTabs({
|
||||
noBlocks,
|
||||
noSources: !dataSources.length,
|
||||
noTools,
|
||||
noStart: !showStartTab,
|
||||
defaultActiveTab,
|
||||
hasUserInputNode,
|
||||
disableStartTab,
|
||||
forceEnableStartTab,
|
||||
})
|
||||
const open = openFromProps === undefined ? localOpen : openFromProps
|
||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||
setLocalOpen(newOpen)
|
||||
|
||||
if (!newOpen)
|
||||
if (!newOpen) {
|
||||
setSearchText('')
|
||||
setSnippetsLoading(false)
|
||||
}
|
||||
else if (activeTab === TabsEnum.Snippets) {
|
||||
setSnippetsLoading(true)
|
||||
}
|
||||
|
||||
if (onOpenChange)
|
||||
onOpenChange(newOpen)
|
||||
}, [onOpenChange])
|
||||
}, [activeTab, onOpenChange])
|
||||
const handleTrigger = useCallback<MouseEventHandler<HTMLDivElement>>((e) => {
|
||||
if (disabled)
|
||||
return
|
||||
@@ -141,23 +167,24 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
onSelect(type, pluginDefaultValue)
|
||||
}, [handleOpenChange, onSelect])
|
||||
|
||||
const {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
tabs,
|
||||
} = useTabs({
|
||||
noBlocks,
|
||||
noSources: !dataSources.length,
|
||||
noTools,
|
||||
noStart: !showStartTab,
|
||||
defaultActiveTab,
|
||||
hasUserInputNode,
|
||||
forceEnableStartTab,
|
||||
})
|
||||
|
||||
const handleActiveTabChange = useCallback((newActiveTab: TabsEnum) => {
|
||||
setActiveTab(newActiveTab)
|
||||
}, [setActiveTab])
|
||||
if (open && newActiveTab === TabsEnum.Snippets)
|
||||
setSnippetsLoading(true)
|
||||
}, [open, setActiveTab])
|
||||
|
||||
useEffect(() => {
|
||||
if (!snippetsLoading)
|
||||
return
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setSnippetsLoading(false)
|
||||
}, 200)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [snippetsLoading])
|
||||
|
||||
const searchPlaceholder = useMemo(() => {
|
||||
if (activeTab === TabsEnum.Start)
|
||||
@@ -171,6 +198,8 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
|
||||
if (activeTab === TabsEnum.Sources)
|
||||
return t('tabs.searchDataSource', { ns: 'workflow' })
|
||||
if (activeTab === TabsEnum.Snippets)
|
||||
return t('tabs.searchSnippets', { ns: 'workflow' })
|
||||
return ''
|
||||
}, [activeTab, t])
|
||||
|
||||
@@ -257,6 +286,17 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
inputClassName="grow"
|
||||
/>
|
||||
)}
|
||||
{activeTab === TabsEnum.Snippets && (
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
autoFocus
|
||||
value={searchText}
|
||||
placeholder={searchPlaceholder}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
onClear={() => setSearchText('')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
onSelect={handleSelect}
|
||||
@@ -268,6 +308,7 @@ const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
noTools={noTools}
|
||||
onTagsChange={setTags}
|
||||
forceShowStartContent={forceShowStartContent}
|
||||
snippetsElem={<Snippets loading={snippetsLoading} searchText={searchText} />}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Snippets from '../index'
|
||||
|
||||
const mockUseInfiniteSnippetList = vi.fn()
|
||||
const mockHandleInsertSnippet = vi.fn()
|
||||
const mockHandleCreateSnippet = vi.fn()
|
||||
const mockHandleOpenCreateSnippetDialog = vi.fn()
|
||||
const mockHandleCloseCreateSnippetDialog = vi.fn()
|
||||
|
||||
vi.mock('ahooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('ahooks')>('ahooks')
|
||||
return {
|
||||
...actual,
|
||||
useInfiniteScroll: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/use-snippets', () => ({
|
||||
useInfiniteSnippetList: (...args: unknown[]) => mockUseInfiniteSnippetList(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../use-insert-snippet', () => ({
|
||||
useInsertSnippet: () => ({
|
||||
handleInsertSnippet: mockHandleInsertSnippet,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-create-snippet', () => ({
|
||||
useCreateSnippet: () => ({
|
||||
createSnippetMutation: { isPending: false },
|
||||
handleCloseCreateSnippetDialog: mockHandleCloseCreateSnippetDialog,
|
||||
handleCreateSnippet: mockHandleCreateSnippet,
|
||||
handleOpenCreateSnippetDialog: mockHandleOpenCreateSnippetDialog,
|
||||
isCreateSnippetDialogOpen: false,
|
||||
isCreatingSnippet: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../../create-snippet-dialog', () => ({
|
||||
default: ({ isOpen }: { isOpen: boolean }) => isOpen ? <div data-testid="create-snippet-dialog" /> : null,
|
||||
}))
|
||||
|
||||
describe('Snippets', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseInfiniteSnippetList.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render loading skeleton when loading', () => {
|
||||
const { container } = render(<Snippets loading searchText="" />)
|
||||
|
||||
expect(container.querySelectorAll('.bg-text-quaternary')).not.toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should render empty state when snippet list is empty', () => {
|
||||
render(<Snippets searchText="" />)
|
||||
|
||||
expect(screen.getByText('workflow.tabs.noSnippetsFound')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render snippet rows from infinite list data', () => {
|
||||
mockUseInfiniteSnippetList.mockReturnValue({
|
||||
data: {
|
||||
pages: [{
|
||||
data: [{
|
||||
id: 'snippet-1',
|
||||
name: 'Customer Review',
|
||||
description: 'Snippet description',
|
||||
author: 'Evan',
|
||||
type: 'group',
|
||||
is_published: true,
|
||||
version: '1.0.0',
|
||||
use_count: 3,
|
||||
icon_info: {
|
||||
icon_type: 'emoji',
|
||||
icon: '🧾',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: '',
|
||||
},
|
||||
input_fields: [],
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
}],
|
||||
}],
|
||||
},
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
})
|
||||
|
||||
render(<Snippets searchText="customer" />)
|
||||
|
||||
expect(mockUseInfiniteSnippetList).toHaveBeenCalledWith({
|
||||
page: 1,
|
||||
limit: 30,
|
||||
keyword: 'customer',
|
||||
is_published: true,
|
||||
})
|
||||
expect(screen.getByText('Customer Review')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should delegate create action from empty state', () => {
|
||||
render(<Snippets searchText="" />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.tabs.createSnippet' }))
|
||||
|
||||
expect(mockHandleOpenCreateSnippetDialog).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should delegate insert action when snippet item is clicked', () => {
|
||||
mockUseInfiniteSnippetList.mockReturnValue({
|
||||
data: {
|
||||
pages: [{
|
||||
data: [{
|
||||
id: 'snippet-1',
|
||||
name: 'Customer Review',
|
||||
description: 'Snippet description',
|
||||
author: 'Evan',
|
||||
type: 'group',
|
||||
is_published: true,
|
||||
version: '1.0.0',
|
||||
use_count: 3,
|
||||
icon_info: {
|
||||
icon_type: 'emoji',
|
||||
icon: '🧾',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: '',
|
||||
},
|
||||
input_fields: [],
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
}],
|
||||
}],
|
||||
},
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasNextPage: false,
|
||||
})
|
||||
|
||||
render(<Snippets searchText="" />)
|
||||
|
||||
fireEvent.click(screen.getByText('Customer Review'))
|
||||
|
||||
expect(mockHandleInsertSnippet).toHaveBeenCalledWith('snippet-1')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { PublishedSnippetListItem } from '../snippet-detail-card'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import SnippetDetailCard from '../snippet-detail-card'
|
||||
|
||||
const mockUseSnippetPublishedWorkflow = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-snippet-workflows', () => ({
|
||||
useSnippetPublishedWorkflow: (...args: unknown[]) => mockUseSnippetPublishedWorkflow(...args),
|
||||
}))
|
||||
|
||||
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,
|
||||
icon_info: {
|
||||
icon_type: 'emoji',
|
||||
icon: '🧾',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: '',
|
||||
},
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('SnippetDetailCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseSnippetPublishedWorkflow.mockReturnValue({ data: undefined })
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render snippet summary information', () => {
|
||||
render(<SnippetDetailCard snippet={createSnippet()} />)
|
||||
|
||||
expect(screen.getByText('Customer Review')).toBeInTheDocument()
|
||||
expect(screen.getByText('Snippet description')).toBeInTheDocument()
|
||||
expect(screen.getByText('Evan')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render unique block icons from published workflow graph', () => {
|
||||
mockUseSnippetPublishedWorkflow.mockReturnValue({
|
||||
data: {
|
||||
graph: {
|
||||
nodes: [
|
||||
{ data: { type: 'llm' } },
|
||||
{ data: { type: 'code' } },
|
||||
{ data: { type: 'llm' } },
|
||||
{ data: { type: 'unknown' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { container } = render(<SnippetDetailCard snippet={createSnippet()} />)
|
||||
|
||||
expect(container.querySelectorAll('[data-icon="Llm"], [data-icon="Code"]')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user