mirror of
https://github.com/langgenius/dify.git
synced 2026-03-25 09:46:53 +00:00
Compare commits
6 Commits
yanli/fix-
...
fix/editor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3817617ce | ||
|
|
f3c2101dad | ||
|
|
9c01195730 | ||
|
|
c1b0922d07 | ||
|
|
e0d57ca19c | ||
|
|
f3c7ee8eb6 |
@@ -7,7 +7,7 @@ import { I18nClientProvider as I18N } from '../app/components/provider/i18n'
|
||||
import commonEnUS from '../i18n/en-US/common.json'
|
||||
|
||||
import '../app/styles/globals.css'
|
||||
import '../app/styles/markdown.css'
|
||||
import '../app/styles/markdown.scss'
|
||||
import './storybook.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
|
||||
@@ -12,15 +12,15 @@ vi.mock('ahooks', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/base/ui/slider', () => ({
|
||||
Slider: (props: { className?: string, min?: number, max?: number, value: number, onValueChange: (value: number) => void }) => (
|
||||
vi.mock('react-slider', () => ({
|
||||
default: (props: { className?: string, min?: number, max?: number, value: number, onChange: (value: number) => void }) => (
|
||||
<input
|
||||
type="range"
|
||||
className={`slider ${props.className ?? ''}`}
|
||||
className={props.className}
|
||||
min={props.min}
|
||||
max={props.max}
|
||||
value={props.value}
|
||||
onChange={e => props.onValueChange(Number(e.target.value))}
|
||||
onChange={e => props.onChange(Number(e.target.value))}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { CuteRobot } from '@/app/components/base/icons/src/vender/solid/communication'
|
||||
import { Unblur } from '@/app/components/base/icons/src/vender/solid/education'
|
||||
import { Slider } from '@/app/components/base/ui/slider'
|
||||
import Slider from '@/app/components/base/slider'
|
||||
import { DEFAULT_AGENT_PROMPT, MAX_ITERATIONS_NUM } from '@/config'
|
||||
import ItemPanel from './item-panel'
|
||||
|
||||
@@ -105,13 +105,12 @@ const AgentSetting: FC<Props> = ({
|
||||
min={maxIterationsMin}
|
||||
max={MAX_ITERATIONS_NUM}
|
||||
value={tempPayload.max_iteration}
|
||||
onValueChange={(value) => {
|
||||
onChange={(value) => {
|
||||
setTempPayload({
|
||||
...tempPayload,
|
||||
max_iteration: value,
|
||||
})
|
||||
}}
|
||||
aria-label={t('agent.setting.maximumIterations.name', { ns: 'appDebug' })}
|
||||
/>
|
||||
|
||||
<input
|
||||
|
||||
@@ -288,8 +288,10 @@ describe('ConfigContent', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const weightedScoreSlider = screen.getByLabelText('dataset.weightedScore.semantic')
|
||||
weightedScoreSlider.focus()
|
||||
const weightedScoreSlider = screen.getAllByRole('slider')
|
||||
.find(slider => slider.getAttribute('aria-valuemax') === '1')
|
||||
expect(weightedScoreSlider).toBeDefined()
|
||||
await user.click(weightedScoreSlider!)
|
||||
const callsBefore = onChange.mock.calls.length
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.weightedScoreSliderTrack {
|
||||
background: var(--color-util-colors-blue-light-blue-light-500) !important;
|
||||
}
|
||||
|
||||
.weightedScoreSliderTrack-1 {
|
||||
background: transparent !important;
|
||||
}
|
||||
@@ -3,8 +3,6 @@ import userEvent from '@testing-library/user-event'
|
||||
import WeightedScore from './weighted-score'
|
||||
|
||||
describe('WeightedScore', () => {
|
||||
const getSliderInput = () => screen.getByLabelText('dataset.weightedScore.semantic')
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@@ -50,8 +48,8 @@ describe('WeightedScore', () => {
|
||||
render(<WeightedScore value={value} onChange={onChange} />)
|
||||
|
||||
// Act
|
||||
const slider = getSliderInput()
|
||||
slider.focus()
|
||||
await user.tab()
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveFocus()
|
||||
const callsBefore = onChange.mock.calls.length
|
||||
await user.keyboard('{ArrowRight}')
|
||||
@@ -71,8 +69,9 @@ describe('WeightedScore', () => {
|
||||
render(<WeightedScore value={value} onChange={onChange} readonly />)
|
||||
|
||||
// Act
|
||||
const slider = getSliderInput()
|
||||
expect(slider).toBeDisabled()
|
||||
await user.tab()
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveFocus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Slider } from '@/app/components/base/ui/slider'
|
||||
|
||||
const weightedScoreSliderStyle: CSSProperties & Record<'--slider-track' | '--slider-range', string> = {
|
||||
'--slider-track': 'var(--color-util-colors-teal-teal-500)',
|
||||
'--slider-range': 'var(--color-util-colors-blue-light-blue-light-500)',
|
||||
}
|
||||
import Slider from '@/app/components/base/slider'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import './weighted-score.css'
|
||||
|
||||
const formatNumber = (value: number) => {
|
||||
if (value > 0 && value < 1)
|
||||
@@ -37,26 +33,24 @@ const WeightedScore = ({
|
||||
return (
|
||||
<div>
|
||||
<div className="space-x-3 rounded-lg border border-components-panel-border px-3 pb-2 pt-5">
|
||||
<div className="grow" style={weightedScoreSliderStyle}>
|
||||
<Slider
|
||||
className="grow"
|
||||
max={1.0}
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={value.value[0]}
|
||||
onValueChange={v => !readonly && onChange({ value: [v, (10 - v * 10) / 10] })}
|
||||
disabled={readonly}
|
||||
aria-label={t('weightedScore.semantic', { ns: 'dataset' })}
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
className={cn('h-0.5 grow rounded-full !bg-util-colors-teal-teal-500')}
|
||||
max={1.0}
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={value.value[0]}
|
||||
onChange={v => !readonly && onChange({ value: [v, (10 - v * 10) / 10] })}
|
||||
trackClassName="weightedScoreSliderTrack"
|
||||
disabled={readonly}
|
||||
/>
|
||||
<div className="mt-3 flex justify-between">
|
||||
<div className="flex w-[90px] shrink-0 items-center text-util-colors-blue-light-blue-light-500 system-xs-semibold-uppercase">
|
||||
<div className="system-xs-semibold-uppercase flex w-[90px] shrink-0 items-center text-util-colors-blue-light-blue-light-500">
|
||||
<div className="mr-1 truncate uppercase" title={t('weightedScore.semantic', { ns: 'dataset' }) || ''}>
|
||||
{t('weightedScore.semantic', { ns: 'dataset' })}
|
||||
</div>
|
||||
{formatNumber(value.value[0])}
|
||||
</div>
|
||||
<div className="flex w-[90px] shrink-0 items-center justify-end text-util-colors-teal-teal-500 system-xs-semibold-uppercase">
|
||||
<div className="system-xs-semibold-uppercase flex w-[90px] shrink-0 items-center justify-end text-util-colors-teal-teal-500">
|
||||
{formatNumber(value.value[1])}
|
||||
<div className="ml-1 truncate uppercase" title={t('weightedScore.keyword', { ns: 'dataset' }) || ''}>
|
||||
{t('weightedScore.keyword', { ns: 'dataset' })}
|
||||
|
||||
@@ -588,66 +588,6 @@ describe('useChat', () => {
|
||||
expect(lastResponse.workflowProcess?.status).toBe('failed')
|
||||
})
|
||||
|
||||
it('should keep separate iteration traces for repeated executions of the same iteration node', async () => {
|
||||
let callbacks: HookCallbacks
|
||||
|
||||
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
|
||||
callbacks = options as HookCallbacks
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useChat())
|
||||
|
||||
act(() => {
|
||||
result.current.handleSend('test-url', { query: 'iteration trace test' }, {})
|
||||
})
|
||||
|
||||
act(() => {
|
||||
callbacks.onWorkflowStarted({ workflow_run_id: 'wr-1', task_id: 't-1' })
|
||||
callbacks.onIterationStart({ data: { id: 'iter-run-1', node_id: 'iter-1' } })
|
||||
callbacks.onIterationStart({ data: { id: 'iter-run-2', node_id: 'iter-1' } })
|
||||
callbacks.onIterationFinish({ data: { id: 'iter-run-1', node_id: 'iter-1', status: 'succeeded' } })
|
||||
callbacks.onIterationFinish({ data: { id: 'iter-run-2', node_id: 'iter-1', status: 'succeeded' } })
|
||||
})
|
||||
|
||||
const tracing = result.current.chatList[1].workflowProcess?.tracing ?? []
|
||||
|
||||
expect(tracing).toHaveLength(2)
|
||||
expect(tracing).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'iter-run-1', status: 'succeeded' }),
|
||||
expect.objectContaining({ id: 'iter-run-2', status: 'succeeded' }),
|
||||
]))
|
||||
})
|
||||
|
||||
it('should keep separate top-level traces for repeated executions of the same node', async () => {
|
||||
let callbacks: HookCallbacks
|
||||
|
||||
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
|
||||
callbacks = options as HookCallbacks
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useChat())
|
||||
|
||||
act(() => {
|
||||
result.current.handleSend('test-url', { query: 'top-level trace test' }, {})
|
||||
})
|
||||
|
||||
act(() => {
|
||||
callbacks.onWorkflowStarted({ workflow_run_id: 'wr-1', task_id: 't-1' })
|
||||
callbacks.onNodeStarted({ data: { id: 'node-run-1', node_id: 'node-1', title: 'Node 1' } })
|
||||
callbacks.onNodeStarted({ data: { id: 'node-run-2', node_id: 'node-1', title: 'Node 1 retry' } })
|
||||
callbacks.onNodeFinished({ data: { id: 'node-run-1', node_id: 'node-1', status: 'succeeded' } })
|
||||
callbacks.onNodeFinished({ data: { id: 'node-run-2', node_id: 'node-1', status: 'succeeded' } })
|
||||
})
|
||||
|
||||
const tracing = result.current.chatList[1].workflowProcess?.tracing ?? []
|
||||
|
||||
expect(tracing).toHaveLength(2)
|
||||
expect(tracing).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'node-run-1', status: 'succeeded' }),
|
||||
expect.objectContaining({ id: 'node-run-2', status: 'succeeded' }),
|
||||
]))
|
||||
})
|
||||
|
||||
it('should handle early exits in tracing events during iteration or loop', async () => {
|
||||
let callbacks: HookCallbacks
|
||||
|
||||
@@ -683,7 +623,7 @@ describe('useChat', () => {
|
||||
callbacks.onNodeFinished({ data: { id: 'n-1', iteration_id: 'iter-1' } })
|
||||
})
|
||||
|
||||
const traceLen1 = result.current.chatList.at(-1)!.workflowProcess?.tracing?.length
|
||||
const traceLen1 = result.current.chatList[result.current.chatList.length - 1].workflowProcess?.tracing?.length
|
||||
expect(traceLen1).toBe(0) // None added due to iteration early hits
|
||||
})
|
||||
|
||||
@@ -767,7 +707,7 @@ describe('useChat', () => {
|
||||
|
||||
expect(result.current.chatList.some(item => item.id === 'question-m-child')).toBe(true)
|
||||
expect(result.current.chatList.some(item => item.id === 'm-child')).toBe(true)
|
||||
expect(result.current.chatList.at(-1)!.content).toBe('child answer')
|
||||
expect(result.current.chatList[result.current.chatList.length - 1].content).toBe('child answer')
|
||||
})
|
||||
|
||||
it('should strip local file urls before sending payload', () => {
|
||||
@@ -865,7 +805,7 @@ describe('useChat', () => {
|
||||
})
|
||||
|
||||
expect(onGetConversationMessages).toHaveBeenCalled()
|
||||
expect(result.current.chatList.at(-1)!.content).toBe('streamed content')
|
||||
expect(result.current.chatList[result.current.chatList.length - 1].content).toBe('streamed content')
|
||||
})
|
||||
|
||||
it('should clear suggested questions when suggestion fetch fails after completion', async () => {
|
||||
@@ -911,7 +851,7 @@ describe('useChat', () => {
|
||||
callbacks.onNodeFinished({ data: { node_id: 'n-loop', id: 'n-loop' } })
|
||||
})
|
||||
|
||||
const latestResponse = result.current.chatList.at(-1)!
|
||||
const latestResponse = result.current.chatList[result.current.chatList.length - 1]
|
||||
expect(latestResponse.workflowProcess?.tracing).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -938,7 +878,7 @@ describe('useChat', () => {
|
||||
callbacks.onTTSChunk('m-th-bind', '')
|
||||
})
|
||||
|
||||
const latestResponse = result.current.chatList.at(-1)!
|
||||
const latestResponse = result.current.chatList[result.current.chatList.length - 1]
|
||||
expect(latestResponse.id).toBe('m-th-bind')
|
||||
expect(latestResponse.conversationId).toBe('c-th-bind')
|
||||
expect(latestResponse.workflowProcess?.status).toBe('succeeded')
|
||||
@@ -1031,7 +971,7 @@ describe('useChat', () => {
|
||||
callbacks.onCompleted()
|
||||
})
|
||||
|
||||
const lastResponse = result.current.chatList.at(-1)!
|
||||
const lastResponse = result.current.chatList[result.current.chatList.length - 1]
|
||||
expect(lastResponse.agent_thoughts![0].thought).toContain('resumed')
|
||||
|
||||
expect(lastResponse.workflowProcess?.tracing?.length).toBeGreaterThan(0)
|
||||
|
||||
@@ -12,7 +12,6 @@ import type {
|
||||
IOnDataMoreInfo,
|
||||
IOtherOptions,
|
||||
} from '@/service/base'
|
||||
import type { NodeTracing } from '@/types/workflow'
|
||||
import { uniqBy } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { produce, setAutoFreeze } from 'immer'
|
||||
@@ -32,7 +31,6 @@ import {
|
||||
} from '@/app/components/base/file-uploader/utils'
|
||||
import { useToastContext } from '@/app/components/base/toast/context'
|
||||
import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { upsertTopLevelTracingNodeOnStart } from '@/app/components/workflow/utils/top-level-tracing'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import { useParams, usePathname } from '@/next/navigation'
|
||||
import {
|
||||
@@ -54,39 +52,6 @@ type SendCallback = {
|
||||
isPublicAPI?: boolean
|
||||
}
|
||||
|
||||
type ParallelTraceLike = Pick<NodeTracing, 'id' | 'node_id' | 'parallel_id' | 'execution_metadata'>
|
||||
|
||||
const findParallelTraceIndex = (
|
||||
tracing: ParallelTraceLike[],
|
||||
data: Partial<ParallelTraceLike>,
|
||||
) => {
|
||||
const incomingParallelId = data.execution_metadata?.parallel_id ?? data.parallel_id
|
||||
|
||||
if (data.id) {
|
||||
const matchedByIdIndex = tracing.findIndex((item) => {
|
||||
if (item.id !== data.id)
|
||||
return false
|
||||
|
||||
const existingParallelId = item.execution_metadata?.parallel_id ?? item.parallel_id
|
||||
if (!existingParallelId || !incomingParallelId)
|
||||
return true
|
||||
|
||||
return existingParallelId === incomingParallelId
|
||||
})
|
||||
|
||||
if (matchedByIdIndex > -1)
|
||||
return matchedByIdIndex
|
||||
}
|
||||
|
||||
return tracing.findIndex((item) => {
|
||||
if (item.node_id !== data.node_id)
|
||||
return false
|
||||
|
||||
const existingParallelId = item.execution_metadata?.parallel_id ?? item.parallel_id
|
||||
return existingParallelId === incomingParallelId
|
||||
})
|
||||
}
|
||||
|
||||
export const useChat = (
|
||||
config?: ChatConfig,
|
||||
formSettings?: {
|
||||
@@ -454,7 +419,8 @@ export const useChat = (
|
||||
if (!responseItem.workflowProcess?.tracing)
|
||||
return
|
||||
const tracing = responseItem.workflowProcess.tracing
|
||||
const iterationIndex = findParallelTraceIndex(tracing, iterationFinishedData)
|
||||
const iterationIndex = tracing.findIndex(item => item.node_id === iterationFinishedData.node_id
|
||||
&& (item.execution_metadata?.parallel_id === iterationFinishedData.execution_metadata?.parallel_id || item.parallel_id === iterationFinishedData.execution_metadata?.parallel_id))!
|
||||
if (iterationIndex > -1) {
|
||||
tracing[iterationIndex] = {
|
||||
...tracing[iterationIndex],
|
||||
@@ -466,34 +432,38 @@ export const useChat = (
|
||||
},
|
||||
onNodeStarted: ({ data: nodeStartedData }) => {
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
if (params.loop_id)
|
||||
return
|
||||
|
||||
if (!responseItem.workflowProcess)
|
||||
return
|
||||
if (!responseItem.workflowProcess.tracing)
|
||||
responseItem.workflowProcess.tracing = []
|
||||
|
||||
upsertTopLevelTracingNodeOnStart(responseItem.workflowProcess.tracing, {
|
||||
...nodeStartedData,
|
||||
status: NodeRunningStatus.Running,
|
||||
})
|
||||
const currentIndex = responseItem.workflowProcess.tracing.findIndex(item => item.node_id === nodeStartedData.node_id)
|
||||
// if the node is already started, update the node
|
||||
if (currentIndex > -1) {
|
||||
responseItem.workflowProcess.tracing[currentIndex] = {
|
||||
...nodeStartedData,
|
||||
status: NodeRunningStatus.Running,
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (nodeStartedData.iteration_id)
|
||||
return
|
||||
|
||||
responseItem.workflowProcess.tracing.push({
|
||||
...nodeStartedData,
|
||||
status: WorkflowRunningStatus.Running,
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
onNodeFinished: ({ data: nodeFinishedData }) => {
|
||||
updateChatTreeNode(messageId, (responseItem) => {
|
||||
if (params.loop_id)
|
||||
return
|
||||
|
||||
if (!responseItem.workflowProcess?.tracing)
|
||||
return
|
||||
|
||||
if (nodeFinishedData.iteration_id)
|
||||
return
|
||||
|
||||
if (nodeFinishedData.loop_id)
|
||||
return
|
||||
|
||||
const currentIndex = responseItem.workflowProcess.tracing.findIndex((item) => {
|
||||
if (!item.execution_metadata?.parallel_id)
|
||||
return item.id === nodeFinishedData.id
|
||||
@@ -535,7 +505,8 @@ export const useChat = (
|
||||
if (!responseItem.workflowProcess?.tracing)
|
||||
return
|
||||
const tracing = responseItem.workflowProcess.tracing
|
||||
const loopIndex = findParallelTraceIndex(tracing, loopFinishedData)
|
||||
const loopIndex = tracing.findIndex(item => item.node_id === loopFinishedData.node_id
|
||||
&& (item.execution_metadata?.parallel_id === loopFinishedData.execution_metadata?.parallel_id || item.parallel_id === loopFinishedData.execution_metadata?.parallel_id))!
|
||||
if (loopIndex > -1) {
|
||||
tracing[loopIndex] = {
|
||||
...tracing[loopIndex],
|
||||
@@ -611,7 +582,7 @@ export const useChat = (
|
||||
{},
|
||||
otherOptions,
|
||||
)
|
||||
}, [updateChatTreeNode, handleResponding, createAudioPlayerManager, config?.suggested_questions_after_answer, params.loop_id])
|
||||
}, [updateChatTreeNode, handleResponding, createAudioPlayerManager, config?.suggested_questions_after_answer])
|
||||
|
||||
const updateCurrentQAOnTree = useCallback(({
|
||||
parentId,
|
||||
@@ -1001,13 +972,12 @@ export const useChat = (
|
||||
},
|
||||
onIterationFinish: ({ data: iterationFinishedData }) => {
|
||||
const tracing = responseItem.workflowProcess!.tracing!
|
||||
const iterationIndex = findParallelTraceIndex(tracing, iterationFinishedData)
|
||||
if (iterationIndex > -1) {
|
||||
tracing[iterationIndex] = {
|
||||
...tracing[iterationIndex],
|
||||
...iterationFinishedData,
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
}
|
||||
const iterationIndex = tracing.findIndex(item => item.node_id === iterationFinishedData.node_id
|
||||
&& (item.execution_metadata?.parallel_id === iterationFinishedData.execution_metadata?.parallel_id || item.parallel_id === iterationFinishedData.execution_metadata?.parallel_id))!
|
||||
tracing[iterationIndex] = {
|
||||
...tracing[iterationIndex],
|
||||
...iterationFinishedData,
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
}
|
||||
|
||||
updateCurrentQAOnTree({
|
||||
@@ -1018,19 +988,30 @@ export const useChat = (
|
||||
})
|
||||
},
|
||||
onNodeStarted: ({ data: nodeStartedData }) => {
|
||||
// `data` is the outer send payload for this request; loop child runs should not emit top-level node traces here.
|
||||
if (data.loop_id)
|
||||
return
|
||||
|
||||
if (!responseItem.workflowProcess)
|
||||
return
|
||||
if (!responseItem.workflowProcess.tracing)
|
||||
responseItem.workflowProcess.tracing = []
|
||||
|
||||
upsertTopLevelTracingNodeOnStart(responseItem.workflowProcess.tracing, {
|
||||
...nodeStartedData,
|
||||
status: NodeRunningStatus.Running,
|
||||
})
|
||||
const currentIndex = responseItem.workflowProcess.tracing.findIndex(item => item.node_id === nodeStartedData.node_id)
|
||||
if (currentIndex > -1) {
|
||||
responseItem.workflowProcess.tracing[currentIndex] = {
|
||||
...nodeStartedData,
|
||||
status: NodeRunningStatus.Running,
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (nodeStartedData.iteration_id)
|
||||
return
|
||||
|
||||
if (data.loop_id)
|
||||
return
|
||||
|
||||
responseItem.workflowProcess.tracing.push({
|
||||
...nodeStartedData,
|
||||
status: WorkflowRunningStatus.Running,
|
||||
})
|
||||
}
|
||||
updateCurrentQAOnTree({
|
||||
placeholderQuestionId,
|
||||
questionItem,
|
||||
@@ -1039,14 +1020,10 @@ export const useChat = (
|
||||
})
|
||||
},
|
||||
onNodeFinished: ({ data: nodeFinishedData }) => {
|
||||
// Use the outer request payload here as well so loop child runs skip top-level finish handling entirely.
|
||||
if (data.loop_id)
|
||||
return
|
||||
|
||||
if (nodeFinishedData.iteration_id)
|
||||
return
|
||||
|
||||
if (nodeFinishedData.loop_id)
|
||||
if (data.loop_id)
|
||||
return
|
||||
|
||||
const currentIndex = responseItem.workflowProcess!.tracing!.findIndex((item) => {
|
||||
@@ -1092,13 +1069,12 @@ export const useChat = (
|
||||
},
|
||||
onLoopFinish: ({ data: loopFinishedData }) => {
|
||||
const tracing = responseItem.workflowProcess!.tracing!
|
||||
const loopIndex = findParallelTraceIndex(tracing, loopFinishedData)
|
||||
if (loopIndex > -1) {
|
||||
tracing[loopIndex] = {
|
||||
...tracing[loopIndex],
|
||||
...loopFinishedData,
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
}
|
||||
const loopIndex = tracing.findIndex(item => item.node_id === loopFinishedData.node_id
|
||||
&& (item.execution_metadata?.parallel_id === loopFinishedData.execution_metadata?.parallel_id || item.parallel_id === loopFinishedData.execution_metadata?.parallel_id))!
|
||||
tracing[loopIndex] = {
|
||||
...tracing[loopIndex],
|
||||
...loopFinishedData,
|
||||
status: WorkflowRunningStatus.Succeeded,
|
||||
}
|
||||
|
||||
updateCurrentQAOnTree({
|
||||
|
||||
@@ -93,6 +93,7 @@ const ConfigParamModal: FC<Props> = ({
|
||||
className="mt-1"
|
||||
value={(annotationConfig.score_threshold || ANNOTATION_DEFAULT.score_threshold) * 100}
|
||||
onChange={(val) => {
|
||||
/* v8 ignore next -- callback dispatch depends on react-slider drag mechanics that are flaky in jsdom. @preserve */
|
||||
setAnnotationConfig({
|
||||
...annotationConfig,
|
||||
score_threshold: val / 100,
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import ScoreSlider from '../index'
|
||||
|
||||
describe('ScoreSlider', () => {
|
||||
const getSliderInput = () => screen.getByLabelText('appDebug.feature.annotation.scoreThreshold.title')
|
||||
vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider', () => ({
|
||||
default: ({ value, onChange, min, max }: { value: number, onChange: (v: number) => void, min: number, max: number }) => (
|
||||
<input
|
||||
type="range"
|
||||
data-testid="slider"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={e => onChange(Number(e.target.value))}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('ScoreSlider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@@ -11,7 +22,7 @@ describe('ScoreSlider', () => {
|
||||
it('should render the slider', () => {
|
||||
render(<ScoreSlider value={90} onChange={vi.fn()} />)
|
||||
|
||||
expect(getSliderInput()).toBeInTheDocument()
|
||||
expect(screen.getByTestId('slider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display easy match and accurate match labels', () => {
|
||||
@@ -26,14 +37,14 @@ describe('ScoreSlider', () => {
|
||||
it('should render with custom className', () => {
|
||||
const { container } = render(<ScoreSlider className="custom-class" value={90} onChange={vi.fn()} />)
|
||||
|
||||
expect(getSliderInput()).toBeInTheDocument()
|
||||
// Verifying the component renders successfully with a custom className
|
||||
expect(screen.getByTestId('slider')).toBeInTheDocument()
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should pass value to the slider', () => {
|
||||
render(<ScoreSlider value={95} onChange={vi.fn()} />)
|
||||
|
||||
expect(getSliderInput()).toHaveValue('95')
|
||||
expect(screen.getByText('0.95')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('slider')).toHaveValue('95')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Slider from '../index'
|
||||
|
||||
describe('BaseSlider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the slider component', () => {
|
||||
render(<Slider value={50} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display the formatted value in the thumb', () => {
|
||||
render(<Slider value={85} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('0.85')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should use default min/max/step when not provided', () => {
|
||||
render(<Slider value={50} onChange={vi.fn()} />)
|
||||
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('aria-valuemin', '0')
|
||||
expect(slider).toHaveAttribute('aria-valuemax', '100')
|
||||
expect(slider).toHaveAttribute('aria-valuenow', '50')
|
||||
})
|
||||
|
||||
it('should use custom min/max/step when provided', () => {
|
||||
render(<Slider value={90} min={80} max={100} step={5} onChange={vi.fn()} />)
|
||||
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('aria-valuemin', '80')
|
||||
expect(slider).toHaveAttribute('aria-valuemax', '100')
|
||||
expect(slider).toHaveAttribute('aria-valuenow', '90')
|
||||
})
|
||||
|
||||
it('should handle NaN value as 0', () => {
|
||||
render(<Slider value={Number.NaN} onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '0')
|
||||
})
|
||||
|
||||
it('should pass disabled prop', () => {
|
||||
render(<Slider value={50} disabled onChange={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import ReactSlider from 'react-slider'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import s from './style.module.css'
|
||||
|
||||
type ISliderProps = {
|
||||
className?: string
|
||||
value: number
|
||||
max?: number
|
||||
min?: number
|
||||
step?: number
|
||||
disabled?: boolean
|
||||
onChange: (value: number) => void
|
||||
}
|
||||
|
||||
const Slider: React.FC<ISliderProps> = ({ className, max, min, step, value, disabled, onChange }) => {
|
||||
return (
|
||||
<ReactSlider
|
||||
disabled={disabled}
|
||||
value={isNaN(value) ? 0 : value}
|
||||
min={min || 0}
|
||||
max={max || 100}
|
||||
step={step || 1}
|
||||
className={cn(className, s.slider)}
|
||||
thumbClassName={cn(s['slider-thumb'], 'top-[-7px] h-[18px] w-2 cursor-pointer rounded-[36px] border !border-black/8 bg-white shadow-md')}
|
||||
trackClassName={s['slider-track']}
|
||||
onChange={onChange}
|
||||
renderThumb={(props, state) => (
|
||||
<div {...props}>
|
||||
<div className="relative h-full w-full">
|
||||
<div className="absolute left-[50%] top-[-16px] translate-x-[-50%] text-text-primary system-sm-semibold">
|
||||
{(state.valueNow / 100).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Slider
|
||||
@@ -0,0 +1,20 @@
|
||||
.slider {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.slider.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.slider-thumb:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
background-color: #528BFF;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.slider-track-1 {
|
||||
background-color: #E5E7EB;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { FC } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Slider } from '@/app/components/base/ui/slider'
|
||||
import Slider from '@/app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
@@ -10,42 +10,23 @@ type Props = {
|
||||
onChange: (value: number) => void
|
||||
}
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => {
|
||||
if (!Number.isFinite(value))
|
||||
return min
|
||||
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
const ScoreSlider: FC<Props> = ({
|
||||
className,
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const safeValue = clamp(value, 80, 100)
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="relative mt-[14px]">
|
||||
<div className="mt-[14px] h-px">
|
||||
<Slider
|
||||
className="w-full"
|
||||
value={safeValue}
|
||||
min={80}
|
||||
max={100}
|
||||
min={80}
|
||||
step={1}
|
||||
onValueChange={onChange}
|
||||
aria-label={t('feature.annotation.scoreThreshold.title', { ns: 'appDebug' })}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute top-[-16px] text-text-primary system-sm-semibold"
|
||||
style={{
|
||||
left: `calc(4px + ${(safeValue - 80) / 20} * (100% - 8px))`,
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
>
|
||||
{(safeValue / 100).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-[10px] flex items-center justify-between system-xs-semibold-uppercase">
|
||||
<div className="flex space-x-1 text-util-colors-cyan-cyan-500">
|
||||
|
||||
@@ -14,14 +14,12 @@ describe('ParamItem Slider onChange', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const getSlider = () => screen.getByLabelText('Test Param')
|
||||
|
||||
it('should divide slider value by 100 when max < 5', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ParamItem {...defaultProps} value={0.5} min={0} max={1} />)
|
||||
const slider = getSlider()
|
||||
const slider = screen.getByRole('slider')
|
||||
|
||||
slider.focus()
|
||||
await user.click(slider)
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
// max=1 < 5, so slider value change (50->51) becomes 0.51
|
||||
@@ -31,9 +29,9 @@ describe('ParamItem Slider onChange', () => {
|
||||
it('should not divide slider value when max >= 5', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ParamItem {...defaultProps} value={5} min={1} max={10} />)
|
||||
const slider = getSlider()
|
||||
const slider = screen.getByRole('slider')
|
||||
|
||||
slider.focus()
|
||||
await user.click(slider)
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
// max=10 >= 5, so value remains raw (5->6)
|
||||
|
||||
@@ -17,8 +17,6 @@ describe('ParamItem', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const getSlider = () => screen.getByLabelText('Test Param')
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the parameter name', () => {
|
||||
render(<ParamItem {...defaultProps} />)
|
||||
@@ -56,7 +54,7 @@ describe('ParamItem', () => {
|
||||
render(<ParamItem {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(getSlider()).toBeInTheDocument()
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,7 +74,7 @@ describe('ParamItem', () => {
|
||||
it('should disable Slider when enable is false', () => {
|
||||
render(<ParamItem {...defaultProps} enable={false} />)
|
||||
|
||||
expect(getSlider()).toBeDisabled()
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
|
||||
it('should set switch value based on enable prop', () => {
|
||||
@@ -137,7 +135,7 @@ describe('ParamItem', () => {
|
||||
await user.clear(input)
|
||||
|
||||
expect(defaultProps.onChange).toHaveBeenLastCalledWith('test_param', 0)
|
||||
expect(getSlider()).toHaveAttribute('aria-valuenow', '0')
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '0')
|
||||
|
||||
await user.tab()
|
||||
|
||||
@@ -168,12 +166,12 @@ describe('ParamItem', () => {
|
||||
await user.type(input, '1.5')
|
||||
|
||||
expect(defaultProps.onChange).toHaveBeenLastCalledWith('test_param', 1)
|
||||
expect(getSlider()).toHaveAttribute('aria-valuenow', '100')
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '100')
|
||||
})
|
||||
|
||||
it('should pass scaled value to slider when max < 5', () => {
|
||||
render(<ParamItem {...defaultProps} value={0.5} />)
|
||||
const slider = getSlider()
|
||||
const slider = screen.getByRole('slider')
|
||||
|
||||
// When max < 5, slider value = value * 100 = 50
|
||||
expect(slider).toHaveAttribute('aria-valuenow', '50')
|
||||
@@ -181,7 +179,7 @@ describe('ParamItem', () => {
|
||||
|
||||
it('should pass raw value to slider when max >= 5', () => {
|
||||
render(<ParamItem {...defaultProps} value={5} max={10} />)
|
||||
const slider = getSlider()
|
||||
const slider = screen.getByRole('slider')
|
||||
|
||||
// When max >= 5, slider value = value = 5
|
||||
expect(slider).toHaveAttribute('aria-valuenow', '5')
|
||||
@@ -214,15 +212,15 @@ describe('ParamItem', () => {
|
||||
render(<ParamItem {...defaultProps} value={0.5} min={0} />)
|
||||
|
||||
// Slider should get value * 100 = 50, min * 100 = 0, max * 100 = 100
|
||||
const slider = getSlider()
|
||||
expect(slider).toHaveAttribute('max', '100')
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('aria-valuemax', '100')
|
||||
})
|
||||
|
||||
it('should not scale slider value when max >= 5', () => {
|
||||
render(<ParamItem {...defaultProps} value={5} min={1} max={10} />)
|
||||
|
||||
const slider = getSlider()
|
||||
expect(slider).toHaveAttribute('max', '10')
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('aria-valuemax', '10')
|
||||
})
|
||||
|
||||
it('should expose default minimum of 0 when min is not provided', () => {
|
||||
|
||||
@@ -14,8 +14,6 @@ describe('ScoreThresholdItem', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const getSlider = () => screen.getByLabelText('appDebug.datasetConfig.score_threshold')
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the translated parameter name', () => {
|
||||
render(<ScoreThresholdItem {...defaultProps} />)
|
||||
@@ -34,7 +32,7 @@ describe('ScoreThresholdItem', () => {
|
||||
render(<ScoreThresholdItem {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(getSlider()).toBeInTheDocument()
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -65,7 +63,7 @@ describe('ScoreThresholdItem', () => {
|
||||
render(<ScoreThresholdItem {...defaultProps} enable={false} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeDisabled()
|
||||
expect(getSlider()).toBeDisabled()
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -19,8 +19,6 @@ describe('TopKItem', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const getSlider = () => screen.getByLabelText('appDebug.datasetConfig.top_k')
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the translated parameter name', () => {
|
||||
render(<TopKItem {...defaultProps} />)
|
||||
@@ -39,7 +37,7 @@ describe('TopKItem', () => {
|
||||
render(<TopKItem {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(getSlider()).toBeInTheDocument()
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -54,7 +52,7 @@ describe('TopKItem', () => {
|
||||
render(<TopKItem {...defaultProps} enable={false} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeDisabled()
|
||||
expect(getSlider()).toBeDisabled()
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -79,10 +77,10 @@ describe('TopKItem', () => {
|
||||
|
||||
it('should render slider with max >= 5 so no scaling is applied', () => {
|
||||
render(<TopKItem {...defaultProps} />)
|
||||
const slider = getSlider()
|
||||
const slider = screen.getByRole('slider')
|
||||
|
||||
// max=10 >= 5 so slider shows raw values
|
||||
expect(slider).toHaveAttribute('max', '10')
|
||||
expect(slider).toHaveAttribute('aria-valuemax', '10')
|
||||
})
|
||||
|
||||
it('should not render a switch (no hasSwitch prop)', () => {
|
||||
@@ -118,9 +116,9 @@ describe('TopKItem', () => {
|
||||
it('should call onChange with integer value when slider changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TopKItem {...defaultProps} value={2} />)
|
||||
const slider = getSlider()
|
||||
const slider = screen.getByRole('slider')
|
||||
|
||||
slider.focus()
|
||||
await user.click(slider)
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
expect(defaultProps.onChange).toHaveBeenLastCalledWith('top_k', 3)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import Slider from '@/app/components/base/slider'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Slider } from '@/app/components/base/ui/slider'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldControls,
|
||||
@@ -78,8 +78,7 @@ const ParamItem: FC<Props> = ({ className, id, name, noTooltip, tip, step = 0.1,
|
||||
value={max < 5 ? value * 100 : value}
|
||||
min={min < 1 ? min * 100 : min}
|
||||
max={max < 5 ? max * 100 : max}
|
||||
onValueChange={value => onChange(id, value / (max < 5 ? 100 : 1))}
|
||||
aria-label={name}
|
||||
onChange={value => onChange(id, value / (max < 5 ? 100 : 1))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,13 +3,10 @@ import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
||||
import { act, render, waitFor } from '@testing-library/react'
|
||||
import {
|
||||
BLUR_COMMAND,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
FOCUS_COMMAND,
|
||||
KEY_ESCAPE_COMMAND,
|
||||
} from 'lexical'
|
||||
import OnBlurBlock from '../on-blur-or-focus-block'
|
||||
import { CaptureEditorPlugin } from '../test-utils'
|
||||
import { CLEAR_HIDE_MENU_TIMEOUT } from '../workflow-variable-block'
|
||||
|
||||
const renderOnBlurBlock = (props?: {
|
||||
onBlur?: () => void
|
||||
@@ -75,7 +72,7 @@ describe('OnBlurBlock', () => {
|
||||
expect(onFocus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onBlur and dispatch escape after delay when blur target is not var-search-input', async () => {
|
||||
it('should call onBlur when blur target is not var-search-input', async () => {
|
||||
const onBlur = vi.fn()
|
||||
const { getEditor } = renderOnBlurBlock({ onBlur })
|
||||
|
||||
@@ -85,14 +82,6 @@ describe('OnBlurBlock', () => {
|
||||
|
||||
const editor = getEditor()
|
||||
expect(editor).not.toBeNull()
|
||||
vi.useFakeTimers()
|
||||
|
||||
const onEscape = vi.fn(() => true)
|
||||
const unregister = editor!.registerCommand(
|
||||
KEY_ESCAPE_COMMAND,
|
||||
onEscape,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
)
|
||||
|
||||
let handled = false
|
||||
act(() => {
|
||||
@@ -101,18 +90,9 @@ describe('OnBlurBlock', () => {
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(onBlur).toHaveBeenCalledTimes(1)
|
||||
expect(onEscape).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
expect(onEscape).toHaveBeenCalledTimes(1)
|
||||
unregister()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should dispatch delayed escape when onBlur callback is not provided', async () => {
|
||||
it('should handle blur when onBlur callback is not provided', async () => {
|
||||
const { getEditor } = renderOnBlurBlock()
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -121,28 +101,16 @@ describe('OnBlurBlock', () => {
|
||||
|
||||
const editor = getEditor()
|
||||
expect(editor).not.toBeNull()
|
||||
vi.useFakeTimers()
|
||||
|
||||
const onEscape = vi.fn(() => true)
|
||||
const unregister = editor!.registerCommand(
|
||||
KEY_ESCAPE_COMMAND,
|
||||
onEscape,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
)
|
||||
|
||||
let handled = false
|
||||
act(() => {
|
||||
editor!.dispatchCommand(BLUR_COMMAND, createBlurEvent(document.createElement('div')))
|
||||
})
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200)
|
||||
handled = editor!.dispatchCommand(BLUR_COMMAND, createBlurEvent(document.createElement('div')))
|
||||
})
|
||||
|
||||
expect(onEscape).toHaveBeenCalledTimes(1)
|
||||
unregister()
|
||||
vi.useRealTimers()
|
||||
expect(handled).toBe(true)
|
||||
})
|
||||
|
||||
it('should skip onBlur and delayed escape when blur target is var-search-input', async () => {
|
||||
it('should skip onBlur when blur target is var-search-input', async () => {
|
||||
const onBlur = vi.fn()
|
||||
const { getEditor } = renderOnBlurBlock({ onBlur })
|
||||
|
||||
@@ -152,31 +120,17 @@ describe('OnBlurBlock', () => {
|
||||
|
||||
const editor = getEditor()
|
||||
expect(editor).not.toBeNull()
|
||||
vi.useFakeTimers()
|
||||
|
||||
const target = document.createElement('input')
|
||||
target.classList.add('var-search-input')
|
||||
|
||||
const onEscape = vi.fn(() => true)
|
||||
const unregister = editor!.registerCommand(
|
||||
KEY_ESCAPE_COMMAND,
|
||||
onEscape,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
)
|
||||
|
||||
let handled = false
|
||||
act(() => {
|
||||
handled = editor!.dispatchCommand(BLUR_COMMAND, createBlurEvent(target))
|
||||
})
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(onBlur).not.toHaveBeenCalled()
|
||||
expect(onEscape).not.toHaveBeenCalled()
|
||||
unregister()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should handle focus command when onFocus callback is not provided', async () => {
|
||||
@@ -198,59 +152,6 @@ describe('OnBlurBlock', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Clear timeout command', () => {
|
||||
it('should clear scheduled escape timeout when clear command is dispatched', async () => {
|
||||
const { getEditor } = renderOnBlurBlock({ onBlur: vi.fn() })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getEditor()).not.toBeNull()
|
||||
})
|
||||
|
||||
const editor = getEditor()
|
||||
expect(editor).not.toBeNull()
|
||||
vi.useFakeTimers()
|
||||
|
||||
const onEscape = vi.fn(() => true)
|
||||
const unregister = editor!.registerCommand(
|
||||
KEY_ESCAPE_COMMAND,
|
||||
onEscape,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
)
|
||||
|
||||
act(() => {
|
||||
editor!.dispatchCommand(BLUR_COMMAND, createBlurEvent(document.createElement('div')))
|
||||
})
|
||||
act(() => {
|
||||
editor!.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined)
|
||||
})
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
expect(onEscape).not.toHaveBeenCalled()
|
||||
unregister()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should handle clear command when no timeout is scheduled', async () => {
|
||||
const { getEditor } = renderOnBlurBlock()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getEditor()).not.toBeNull()
|
||||
})
|
||||
|
||||
const editor = getEditor()
|
||||
expect(editor).not.toBeNull()
|
||||
|
||||
let handled = false
|
||||
act(() => {
|
||||
handled = editor!.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined)
|
||||
})
|
||||
|
||||
expect(handled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Lifecycle cleanup', () => {
|
||||
it('should unregister commands when component unmounts', async () => {
|
||||
const { getEditor, unmount } = renderOnBlurBlock()
|
||||
@@ -266,16 +167,13 @@ describe('OnBlurBlock', () => {
|
||||
|
||||
let blurHandled = true
|
||||
let focusHandled = true
|
||||
let clearHandled = true
|
||||
act(() => {
|
||||
blurHandled = editor!.dispatchCommand(BLUR_COMMAND, createBlurEvent(document.createElement('div')))
|
||||
focusHandled = editor!.dispatchCommand(FOCUS_COMMAND, createFocusEvent())
|
||||
clearHandled = editor!.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined)
|
||||
})
|
||||
|
||||
expect(blurHandled).toBe(false)
|
||||
expect(focusHandled).toBe(false)
|
||||
expect(clearHandled).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import type { LexicalEditor } from 'lexical'
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
||||
import { act, render, waitFor } from '@testing-library/react'
|
||||
import { $getRoot, COMMAND_PRIORITY_EDITOR } from 'lexical'
|
||||
import { $getRoot } from 'lexical'
|
||||
import { CustomTextNode } from '../custom-text/node'
|
||||
import { CaptureEditorPlugin } from '../test-utils'
|
||||
import UpdateBlock, {
|
||||
PROMPT_EDITOR_INSERT_QUICKLY,
|
||||
PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER,
|
||||
} from '../update-block'
|
||||
import { CLEAR_HIDE_MENU_TIMEOUT } from '../workflow-variable-block'
|
||||
|
||||
const { mockUseEventEmitterContextContext } = vi.hoisted(() => ({
|
||||
mockUseEventEmitterContextContext: vi.fn(),
|
||||
@@ -157,7 +156,7 @@ describe('UpdateBlock', () => {
|
||||
})
|
||||
|
||||
describe('Quick insert event', () => {
|
||||
it('should insert slash and dispatch clear command when quick insert event matches instance id', async () => {
|
||||
it('should insert slash when quick insert event matches instance id', async () => {
|
||||
const { emit, getEditor } = setup({ instanceId: 'instance-1' })
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -168,13 +167,6 @@ describe('UpdateBlock', () => {
|
||||
|
||||
selectRootEnd(editor!)
|
||||
|
||||
const clearCommandHandler = vi.fn(() => true)
|
||||
const unregister = editor!.registerCommand(
|
||||
CLEAR_HIDE_MENU_TIMEOUT,
|
||||
clearCommandHandler,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
)
|
||||
|
||||
emit({
|
||||
type: PROMPT_EDITOR_INSERT_QUICKLY,
|
||||
instanceId: 'instance-1',
|
||||
@@ -183,9 +175,6 @@ describe('UpdateBlock', () => {
|
||||
await waitFor(() => {
|
||||
expect(readEditorText(editor!)).toBe('/')
|
||||
})
|
||||
expect(clearCommandHandler).toHaveBeenCalledTimes(1)
|
||||
|
||||
unregister()
|
||||
})
|
||||
|
||||
it('should ignore quick insert event when instance id does not match', async () => {
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
$createTextNode,
|
||||
$getRoot,
|
||||
$setSelection,
|
||||
BLUR_COMMAND,
|
||||
FOCUS_COMMAND,
|
||||
KEY_ESCAPE_COMMAND,
|
||||
} from 'lexical'
|
||||
import * as React from 'react'
|
||||
@@ -631,4 +633,180 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => {
|
||||
// With a single option group, the only divider should be the workflow-var/options separator.
|
||||
expect(document.querySelectorAll('.bg-divider-subtle')).toHaveLength(1)
|
||||
})
|
||||
|
||||
describe('blur/focus menu visibility', () => {
|
||||
it('hides the menu after a 200ms delay when blur command is dispatched', async () => {
|
||||
const captures: Captures = { editor: null, eventEmitter: null }
|
||||
|
||||
render((
|
||||
<MinimalEditor
|
||||
triggerString="{"
|
||||
contextBlock={makeContextBlock()}
|
||||
captures={captures}
|
||||
/>
|
||||
))
|
||||
|
||||
const editor = await waitForEditor(captures)
|
||||
await setEditorText(editor, '{', true)
|
||||
expect(await screen.findByText('common.promptEditor.context.item.title')).toBeInTheDocument()
|
||||
|
||||
vi.useFakeTimers()
|
||||
|
||||
act(() => {
|
||||
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: document.createElement('button') }))
|
||||
})
|
||||
|
||||
expect(screen.queryByText('common.promptEditor.context.item.title')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
expect(screen.queryByText('common.promptEditor.context.item.title')).not.toBeInTheDocument()
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('restores menu visibility when focus command is dispatched after blur hides it', async () => {
|
||||
const captures: Captures = { editor: null, eventEmitter: null }
|
||||
|
||||
render((
|
||||
<MinimalEditor
|
||||
triggerString="{"
|
||||
contextBlock={makeContextBlock()}
|
||||
captures={captures}
|
||||
/>
|
||||
))
|
||||
|
||||
const editor = await waitForEditor(captures)
|
||||
await setEditorText(editor, '{', true)
|
||||
expect(await screen.findByText('common.promptEditor.context.item.title')).toBeInTheDocument()
|
||||
|
||||
vi.useFakeTimers()
|
||||
|
||||
act(() => {
|
||||
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: document.createElement('button') }))
|
||||
})
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
expect(screen.queryByText('common.promptEditor.context.item.title')).not.toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
editor.dispatchCommand(FOCUS_COMMAND, new FocusEvent('focus'))
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
|
||||
await setEditorText(editor, '{', true)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('common.promptEditor.context.item.title')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('cancels the blur timer when focus arrives before the 200ms timeout', async () => {
|
||||
const captures: Captures = { editor: null, eventEmitter: null }
|
||||
|
||||
render((
|
||||
<MinimalEditor
|
||||
triggerString="{"
|
||||
contextBlock={makeContextBlock()}
|
||||
captures={captures}
|
||||
/>
|
||||
))
|
||||
|
||||
const editor = await waitForEditor(captures)
|
||||
await setEditorText(editor, '{', true)
|
||||
expect(await screen.findByText('common.promptEditor.context.item.title')).toBeInTheDocument()
|
||||
|
||||
vi.useFakeTimers()
|
||||
|
||||
act(() => {
|
||||
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: document.createElement('button') }))
|
||||
})
|
||||
|
||||
act(() => {
|
||||
editor.dispatchCommand(FOCUS_COMMAND, new FocusEvent('focus'))
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
expect(screen.queryByText('common.promptEditor.context.item.title')).toBeInTheDocument()
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('cancels a pending blur timer when a subsequent blur targets var-search-input', async () => {
|
||||
const captures: Captures = { editor: null, eventEmitter: null }
|
||||
|
||||
render((
|
||||
<MinimalEditor
|
||||
triggerString="{"
|
||||
contextBlock={makeContextBlock()}
|
||||
captures={captures}
|
||||
/>
|
||||
))
|
||||
|
||||
const editor = await waitForEditor(captures)
|
||||
await setEditorText(editor, '{', true)
|
||||
expect(await screen.findByText('common.promptEditor.context.item.title')).toBeInTheDocument()
|
||||
|
||||
vi.useFakeTimers()
|
||||
|
||||
act(() => {
|
||||
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: document.createElement('button') }))
|
||||
})
|
||||
|
||||
const varInput = document.createElement('input')
|
||||
varInput.classList.add('var-search-input')
|
||||
|
||||
act(() => {
|
||||
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: varInput }))
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
expect(screen.queryByText('common.promptEditor.context.item.title')).toBeInTheDocument()
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('does not hide the menu when blur target is var-search-input', async () => {
|
||||
const captures: Captures = { editor: null, eventEmitter: null }
|
||||
|
||||
render((
|
||||
<MinimalEditor
|
||||
triggerString="{"
|
||||
contextBlock={makeContextBlock()}
|
||||
captures={captures}
|
||||
/>
|
||||
))
|
||||
|
||||
const editor = await waitForEditor(captures)
|
||||
await setEditorText(editor, '{', true)
|
||||
expect(await screen.findByText('common.promptEditor.context.item.title')).toBeInTheDocument()
|
||||
|
||||
vi.useFakeTimers()
|
||||
|
||||
const target = document.createElement('input')
|
||||
target.classList.add('var-search-input')
|
||||
|
||||
act(() => {
|
||||
editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur', { relatedTarget: target }))
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
expect(screen.queryByText('common.promptEditor.context.item.title')).toBeInTheDocument()
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,11 +21,19 @@ import {
|
||||
} from '@floating-ui/react'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { LexicalTypeaheadMenuPlugin } from '@lexical/react/LexicalTypeaheadMenuPlugin'
|
||||
import { KEY_ESCAPE_COMMAND } from 'lexical'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import {
|
||||
BLUR_COMMAND,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
FOCUS_COMMAND,
|
||||
KEY_ESCAPE_COMMAND,
|
||||
} from 'lexical'
|
||||
import {
|
||||
Fragment,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
@@ -87,6 +95,46 @@ const ComponentPicker = ({
|
||||
})
|
||||
|
||||
const [queryString, setQueryString] = useState<string | null>(null)
|
||||
const [blurHidden, setBlurHidden] = useState(false)
|
||||
const blurTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const clearBlurTimer = useCallback(() => {
|
||||
if (blurTimerRef.current) {
|
||||
clearTimeout(blurTimerRef.current)
|
||||
blurTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const unregister = mergeRegister(
|
||||
editor.registerCommand(
|
||||
BLUR_COMMAND,
|
||||
(event) => {
|
||||
clearBlurTimer()
|
||||
const target = event?.relatedTarget as HTMLElement
|
||||
if (!target?.classList?.contains('var-search-input'))
|
||||
blurTimerRef.current = setTimeout(() => setBlurHidden(true), 200)
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
editor.registerCommand(
|
||||
FOCUS_COMMAND,
|
||||
() => {
|
||||
clearBlurTimer()
|
||||
setBlurHidden(false)
|
||||
return false
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
)
|
||||
|
||||
return () => {
|
||||
if (blurTimerRef.current)
|
||||
clearTimeout(blurTimerRef.current)
|
||||
unregister()
|
||||
}
|
||||
}, [editor, clearBlurTimer])
|
||||
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
if (v.type === INSERT_VARIABLE_VALUE_BLOCK_COMMAND)
|
||||
@@ -159,6 +207,8 @@ const ComponentPicker = ({
|
||||
anchorElementRef,
|
||||
{ options, selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
|
||||
) => {
|
||||
if (blurHidden)
|
||||
return null
|
||||
if (!(anchorElementRef.current && (allFlattenOptions.length || workflowVariableBlock?.show)))
|
||||
return null
|
||||
|
||||
@@ -240,7 +290,7 @@ const ComponentPicker = ({
|
||||
}
|
||||
</>
|
||||
)
|
||||
}, [allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, workflowVariableOptions, isSupportFileVar, handleClose, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField])
|
||||
}, [blurHidden, allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, workflowVariableOptions, isSupportFileVar, handleClose, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField])
|
||||
|
||||
return (
|
||||
<LexicalTypeaheadMenuPlugin
|
||||
|
||||
@@ -5,10 +5,8 @@ import {
|
||||
BLUR_COMMAND,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
FOCUS_COMMAND,
|
||||
KEY_ESCAPE_COMMAND,
|
||||
} from 'lexical'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { CLEAR_HIDE_MENU_TIMEOUT } from './workflow-variable-block'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
type OnBlurBlockProps = {
|
||||
onBlur?: () => void
|
||||
@@ -20,35 +18,13 @@ const OnBlurBlock: FC<OnBlurBlockProps> = ({
|
||||
}) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
const ref = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const clearHideMenuTimeout = () => {
|
||||
if (ref.current) {
|
||||
clearTimeout(ref.current)
|
||||
ref.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const unregister = mergeRegister(
|
||||
editor.registerCommand(
|
||||
CLEAR_HIDE_MENU_TIMEOUT,
|
||||
() => {
|
||||
clearHideMenuTimeout()
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
return mergeRegister(
|
||||
editor.registerCommand(
|
||||
BLUR_COMMAND,
|
||||
(event) => {
|
||||
// Check if the clicked target element is var-search-input
|
||||
const target = event?.relatedTarget as HTMLElement
|
||||
if (!target?.classList?.contains('var-search-input')) {
|
||||
clearHideMenuTimeout()
|
||||
ref.current = setTimeout(() => {
|
||||
editor.dispatchCommand(KEY_ESCAPE_COMMAND, new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||
}, 200)
|
||||
if (onBlur)
|
||||
onBlur()
|
||||
}
|
||||
@@ -66,11 +42,6 @@ const OnBlurBlock: FC<OnBlurBlockProps> = ({
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
)
|
||||
|
||||
return () => {
|
||||
clearHideMenuTimeout()
|
||||
unregister()
|
||||
}
|
||||
}, [editor, onBlur, onFocus])
|
||||
|
||||
return null
|
||||
|
||||
@@ -3,7 +3,6 @@ import { $insertNodes } from 'lexical'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { textToEditorState } from '../utils'
|
||||
import { CustomTextNode } from './custom-text/node'
|
||||
import { CLEAR_HIDE_MENU_TIMEOUT } from './workflow-variable-block'
|
||||
|
||||
export const PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER = 'PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER'
|
||||
export const PROMPT_EDITOR_INSERT_QUICKLY = 'PROMPT_EDITOR_INSERT_QUICKLY'
|
||||
@@ -30,8 +29,6 @@ const UpdateBlock = ({
|
||||
editor.update(() => {
|
||||
const textNode = new CustomTextNode('/')
|
||||
$insertNodes([textNode])
|
||||
|
||||
editor.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -9,7 +9,6 @@ import { $insertNodes, COMMAND_PRIORITY_EDITOR } from 'lexical'
|
||||
import { Type } from '@/app/components/workflow/nodes/llm/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import {
|
||||
CLEAR_HIDE_MENU_TIMEOUT,
|
||||
DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND,
|
||||
INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND,
|
||||
UPDATE_WORKFLOW_NODES_MAP,
|
||||
@@ -134,7 +133,6 @@ describe('WorkflowVariableBlock', () => {
|
||||
const insertHandler = mockRegisterCommand.mock.calls[0][1] as (variables: string[]) => boolean
|
||||
const result = insertHandler(['node-1', 'answer'])
|
||||
|
||||
expect(mockDispatchCommand).toHaveBeenCalledWith(CLEAR_HIDE_MENU_TIMEOUT, undefined)
|
||||
expect($createWorkflowVariableBlockNode).toHaveBeenCalledWith(
|
||||
['node-1', 'answer'],
|
||||
workflowNodesMap,
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
|
||||
export const INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND = createCommand('INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND')
|
||||
export const DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND = createCommand('DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND')
|
||||
export const CLEAR_HIDE_MENU_TIMEOUT = createCommand('CLEAR_HIDE_MENU_TIMEOUT')
|
||||
export const UPDATE_WORKFLOW_NODES_MAP = createCommand('UPDATE_WORKFLOW_NODES_MAP')
|
||||
|
||||
export type WorkflowVariableBlockProps = {
|
||||
@@ -49,7 +48,6 @@ const WorkflowVariableBlock = memo(({
|
||||
editor.registerCommand(
|
||||
INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND,
|
||||
(variables: string[]) => {
|
||||
editor.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined)
|
||||
const workflowVariableBlockNode = $createWorkflowVariableBlockNode(variables, workflowNodesMap, getVarType)
|
||||
|
||||
$insertNodes([workflowVariableBlockNode])
|
||||
|
||||
77
web/app/components/base/slider/__tests__/index.spec.tsx
Normal file
77
web/app/components/base/slider/__tests__/index.spec.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import Slider from '../index'
|
||||
|
||||
describe('Slider Component', () => {
|
||||
it('should render with correct default ARIA limits and current value', () => {
|
||||
render(<Slider value={50} onChange={vi.fn()} />)
|
||||
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('aria-valuemin', '0')
|
||||
expect(slider).toHaveAttribute('aria-valuemax', '100')
|
||||
expect(slider).toHaveAttribute('aria-valuenow', '50')
|
||||
})
|
||||
|
||||
it('should apply custom min, max, and step values', () => {
|
||||
render(<Slider value={10} min={5} max={20} step={5} onChange={vi.fn()} />)
|
||||
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('aria-valuemin', '5')
|
||||
expect(slider).toHaveAttribute('aria-valuemax', '20')
|
||||
expect(slider).toHaveAttribute('aria-valuenow', '10')
|
||||
})
|
||||
|
||||
it('should default to 0 if the value prop is NaN', () => {
|
||||
render(<Slider value={Number.NaN} onChange={vi.fn()} />)
|
||||
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('aria-valuenow', '0')
|
||||
})
|
||||
|
||||
it('should call onChange when arrow keys are pressed', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(<Slider value={20} onChange={onChange} />)
|
||||
|
||||
const slider = screen.getByRole('slider')
|
||||
|
||||
await act(async () => {
|
||||
slider.focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
})
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
expect(onChange).toHaveBeenCalledWith(21, 0)
|
||||
})
|
||||
|
||||
it('should not trigger onChange when disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<Slider value={20} onChange={onChange} disabled />)
|
||||
|
||||
const slider = screen.getByRole('slider')
|
||||
|
||||
expect(slider).toHaveAttribute('aria-disabled', 'true')
|
||||
|
||||
await act(async () => {
|
||||
slider.focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
})
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should apply custom class names', () => {
|
||||
render(
|
||||
<Slider value={10} onChange={vi.fn()} className="outer-test" thumbClassName="thumb-test" />,
|
||||
)
|
||||
|
||||
const sliderWrapper = screen.getByRole('slider').closest('.outer-test')
|
||||
expect(sliderWrapper).toBeInTheDocument()
|
||||
|
||||
const thumb = screen.getByRole('slider')
|
||||
expect(thumb).toHaveClass('thumb-test')
|
||||
})
|
||||
})
|
||||
635
web/app/components/base/slider/index.stories.tsx
Normal file
635
web/app/components/base/slider/index.stories.tsx
Normal file
@@ -0,0 +1,635 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { useState } from 'react'
|
||||
import Slider from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/Slider',
|
||||
component: Slider,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Slider component for selecting a numeric value within a range. Built on react-slider with customizable min/max/step values.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
value: {
|
||||
control: 'number',
|
||||
description: 'Current slider value',
|
||||
},
|
||||
min: {
|
||||
control: 'number',
|
||||
description: 'Minimum value (default: 0)',
|
||||
},
|
||||
max: {
|
||||
control: 'number',
|
||||
description: 'Maximum value (default: 100)',
|
||||
},
|
||||
step: {
|
||||
control: 'number',
|
||||
description: 'Step increment (default: 1)',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Disabled state',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
onChange: (value) => {
|
||||
console.log('Slider value:', value)
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Slider>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const SliderDemo = (args: any) => {
|
||||
const [value, setValue] = useState(args.value || 50)
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }}>
|
||||
<Slider
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
setValue(v)
|
||||
console.log('Slider value:', v)
|
||||
}}
|
||||
/>
|
||||
<div className="mt-4 text-center text-sm text-gray-600">
|
||||
Value:
|
||||
{' '}
|
||||
<span className="text-lg font-semibold">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
render: args => <SliderDemo {...args} />,
|
||||
args: {
|
||||
value: 50,
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
disabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
// With custom range
|
||||
export const CustomRange: Story = {
|
||||
render: args => <SliderDemo {...args} />,
|
||||
args: {
|
||||
value: 25,
|
||||
min: 0,
|
||||
max: 50,
|
||||
step: 1,
|
||||
disabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
// With step increment
|
||||
export const WithStepIncrement: Story = {
|
||||
render: args => <SliderDemo {...args} />,
|
||||
args: {
|
||||
value: 50,
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 10,
|
||||
disabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Decimal values
|
||||
export const DecimalValues: Story = {
|
||||
render: args => <SliderDemo {...args} />,
|
||||
args: {
|
||||
value: 2.5,
|
||||
min: 0,
|
||||
max: 5,
|
||||
step: 0.5,
|
||||
disabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled state
|
||||
export const Disabled: Story = {
|
||||
render: args => <SliderDemo {...args} />,
|
||||
args: {
|
||||
value: 75,
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Real-world example - Volume control
|
||||
const VolumeControlDemo = () => {
|
||||
const [volume, setVolume] = useState(70)
|
||||
|
||||
const getVolumeIcon = (vol: number) => {
|
||||
if (vol === 0)
|
||||
return '🔇'
|
||||
if (vol < 33)
|
||||
return '🔈'
|
||||
if (vol < 66)
|
||||
return '🔉'
|
||||
return '🔊'
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Volume Control</h3>
|
||||
<span className="text-2xl">{getVolumeIcon(volume)}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={volume}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
onChange={setVolume}
|
||||
/>
|
||||
<div className="mt-4 flex items-center justify-between text-sm text-gray-600">
|
||||
<span>Mute</span>
|
||||
<span className="text-lg font-semibold">
|
||||
{volume}
|
||||
%
|
||||
</span>
|
||||
<span>Max</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const VolumeControl: Story = {
|
||||
render: () => <VolumeControlDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Brightness control
|
||||
const BrightnessControlDemo = () => {
|
||||
const [brightness, setBrightness] = useState(80)
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Screen Brightness</h3>
|
||||
<span className="text-2xl">☀️</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={brightness}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
onChange={setBrightness}
|
||||
/>
|
||||
<div className="mt-4 rounded-lg bg-gray-50 p-4" style={{ opacity: brightness / 100 }}>
|
||||
<div className="text-sm text-gray-700">
|
||||
Preview at
|
||||
{' '}
|
||||
{brightness}
|
||||
% brightness
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const BrightnessControl: Story = {
|
||||
render: () => <BrightnessControlDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Price range filter
|
||||
const PriceRangeFilterDemo = () => {
|
||||
const [maxPrice, setMaxPrice] = useState(500)
|
||||
const minPrice = 0
|
||||
|
||||
const products = [
|
||||
{ name: 'Product A', price: 150 },
|
||||
{ name: 'Product B', price: 350 },
|
||||
{ name: 'Product C', price: 600 },
|
||||
{ name: 'Product D', price: 250 },
|
||||
{ name: 'Product E', price: 450 },
|
||||
]
|
||||
|
||||
const filteredProducts = products.filter(p => p.price >= minPrice && p.price <= maxPrice)
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Filter by Price</h3>
|
||||
<div className="mb-2">
|
||||
<div className="mb-2 flex items-center justify-between text-sm text-gray-600">
|
||||
<span>Maximum Price</span>
|
||||
<span className="font-semibold text-gray-900">
|
||||
$
|
||||
{maxPrice}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={maxPrice}
|
||||
min={0}
|
||||
max={1000}
|
||||
step={50}
|
||||
onChange={setMaxPrice}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<div className="mb-3 text-sm font-medium text-gray-700">
|
||||
Showing
|
||||
{' '}
|
||||
{filteredProducts.length}
|
||||
{' '}
|
||||
of
|
||||
{' '}
|
||||
{products.length}
|
||||
{' '}
|
||||
products
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{filteredProducts.map(product => (
|
||||
<div key={product.name} className="flex items-center justify-between rounded-lg bg-gray-50 p-3">
|
||||
<span className="text-sm">{product.name}</span>
|
||||
<span className="font-semibold text-gray-900">
|
||||
$
|
||||
{product.price}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PriceRangeFilter: Story = {
|
||||
render: () => <PriceRangeFilterDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Temperature selector
|
||||
const TemperatureSelectorDemo = () => {
|
||||
const [temperature, setTemperature] = useState(22)
|
||||
const fahrenheit = ((temperature * 9) / 5 + 32).toFixed(1)
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Thermostat Control</h3>
|
||||
<div className="mb-6">
|
||||
<Slider
|
||||
value={temperature}
|
||||
min={16}
|
||||
max={30}
|
||||
step={0.5}
|
||||
onChange={setTemperature}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="rounded-lg bg-blue-50 p-4 text-center">
|
||||
<div className="mb-1 text-xs text-gray-600">Celsius</div>
|
||||
<div className="text-3xl font-bold text-blue-600">
|
||||
{temperature}
|
||||
°C
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-orange-50 p-4 text-center">
|
||||
<div className="mb-1 text-xs text-gray-600">Fahrenheit</div>
|
||||
<div className="text-3xl font-bold text-orange-600">
|
||||
{fahrenheit}
|
||||
°F
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-center text-xs text-gray-500">
|
||||
{temperature < 18 && '🥶 Too cold'}
|
||||
{temperature >= 18 && temperature <= 24 && '😊 Comfortable'}
|
||||
{temperature > 24 && '🥵 Too warm'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TemperatureSelector: Story = {
|
||||
render: () => <TemperatureSelectorDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Progress/completion slider
|
||||
const ProgressSliderDemo = () => {
|
||||
const [progress, setProgress] = useState(65)
|
||||
|
||||
return (
|
||||
<div style={{ width: '450px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Project Completion</h3>
|
||||
<Slider
|
||||
value={progress}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
onChange={setProgress}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">Progress</span>
|
||||
<span className="text-lg font-bold text-blue-600">
|
||||
{progress}
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={progress >= 25 ? '✅' : '⏳'}>Planning</span>
|
||||
<span className="text-xs text-gray-500">25%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={progress >= 50 ? '✅' : '⏳'}>Development</span>
|
||||
<span className="text-xs text-gray-500">50%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={progress >= 75 ? '✅' : '⏳'}>Testing</span>
|
||||
<span className="text-xs text-gray-500">75%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={progress >= 100 ? '✅' : '⏳'}>Deployment</span>
|
||||
<span className="text-xs text-gray-500">100%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProgressSlider: Story = {
|
||||
render: () => <ProgressSliderDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Zoom control
|
||||
const ZoomControlDemo = () => {
|
||||
const [zoom, setZoom] = useState(100)
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Zoom Level</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="rounded bg-gray-200 px-3 py-1 text-sm hover:bg-gray-300"
|
||||
onClick={() => setZoom(Math.max(50, zoom - 10))}
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<Slider
|
||||
value={zoom}
|
||||
min={50}
|
||||
max={200}
|
||||
step={10}
|
||||
onChange={setZoom}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="rounded bg-gray-200 px-3 py-1 text-sm hover:bg-gray-300"
|
||||
onClick={() => setZoom(Math.min(200, zoom + 10))}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between text-sm text-gray-600">
|
||||
<span>50%</span>
|
||||
<span className="text-lg font-semibold">
|
||||
{zoom}
|
||||
%
|
||||
</span>
|
||||
<span>200%</span>
|
||||
</div>
|
||||
<div className="mt-4 rounded-lg bg-gray-50 p-4 text-center" style={{ transform: `scale(${zoom / 100})`, transformOrigin: 'center' }}>
|
||||
<div className="text-sm">Preview content</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ZoomControl: Story = {
|
||||
render: () => <ZoomControlDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - AI model parameters
|
||||
const AIModelParametersDemo = () => {
|
||||
const [temperature, setTemperature] = useState(0.7)
|
||||
const [maxTokens, setMaxTokens] = useState(2000)
|
||||
const [topP, setTopP] = useState(0.9)
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Model Configuration</h3>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-700">Temperature</label>
|
||||
<span className="text-sm font-semibold">{temperature}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={temperature}
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
onChange={setTemperature}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Controls randomness. Lower is more focused, higher is more creative.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-700">Max Tokens</label>
|
||||
<span className="text-sm font-semibold">{maxTokens}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={maxTokens}
|
||||
min={100}
|
||||
max={4000}
|
||||
step={100}
|
||||
onChange={setMaxTokens}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Maximum length of generated response.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-700">Top P</label>
|
||||
<span className="text-sm font-semibold">{topP}</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={topP}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
onChange={setTopP}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Nucleus sampling threshold.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 rounded-lg bg-blue-50 p-4 text-xs text-gray-700">
|
||||
<div>
|
||||
<strong>Temperature:</strong>
|
||||
{' '}
|
||||
{temperature}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Max Tokens:</strong>
|
||||
{' '}
|
||||
{maxTokens}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Top P:</strong>
|
||||
{' '}
|
||||
{topP}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const AIModelParameters: Story = {
|
||||
render: () => <AIModelParametersDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Image quality selector
|
||||
const ImageQualitySelectorDemo = () => {
|
||||
const [quality, setQuality] = useState(80)
|
||||
|
||||
const getQualityLabel = (q: number) => {
|
||||
if (q < 50)
|
||||
return 'Low'
|
||||
if (q < 70)
|
||||
return 'Medium'
|
||||
if (q < 90)
|
||||
return 'High'
|
||||
return 'Maximum'
|
||||
}
|
||||
|
||||
const estimatedSize = Math.round((quality / 100) * 5)
|
||||
|
||||
return (
|
||||
<div style={{ width: '450px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Image Export Quality</h3>
|
||||
<Slider
|
||||
value={quality}
|
||||
min={10}
|
||||
max={100}
|
||||
step={10}
|
||||
onChange={setQuality}
|
||||
/>
|
||||
<div className="mt-4 grid grid-cols-2 gap-4">
|
||||
<div className="rounded-lg bg-gray-50 p-3">
|
||||
<div className="text-xs text-gray-600">Quality</div>
|
||||
<div className="text-lg font-semibold">{getQualityLabel(quality)}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{quality}
|
||||
%
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-gray-50 p-3">
|
||||
<div className="text-xs text-gray-600">File Size</div>
|
||||
<div className="text-lg font-semibold">
|
||||
~
|
||||
{estimatedSize}
|
||||
{' '}
|
||||
MB
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Estimated</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ImageQualitySelector: Story = {
|
||||
render: () => <ImageQualitySelectorDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Multiple sliders
|
||||
const MultipleSlidersDemo = () => {
|
||||
const [red, setRed] = useState(128)
|
||||
const [green, setGreen] = useState(128)
|
||||
const [blue, setBlue] = useState(128)
|
||||
|
||||
const rgbColor = `rgb(${red}, ${green}, ${blue})`
|
||||
|
||||
return (
|
||||
<div style={{ width: '450px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">RGB Color Picker</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-red-600">Red</label>
|
||||
<span className="text-sm font-semibold">{red}</span>
|
||||
</div>
|
||||
<Slider value={red} min={0} max={255} step={1} onChange={setRed} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-green-600">Green</label>
|
||||
<span className="text-sm font-semibold">{green}</span>
|
||||
</div>
|
||||
<Slider value={green} min={0} max={255} step={1} onChange={setGreen} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-blue-600">Blue</label>
|
||||
<span className="text-sm font-semibold">{blue}</span>
|
||||
</div>
|
||||
<Slider value={blue} min={0} max={255} step={1} onChange={setBlue} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div
|
||||
className="h-24 w-24 rounded-lg border-2 border-gray-300"
|
||||
style={{ backgroundColor: rgbColor }}
|
||||
/>
|
||||
<div className="text-right">
|
||||
<div className="mb-1 text-xs text-gray-600">Color Value</div>
|
||||
<div className="font-mono text-sm font-semibold">{rgbColor}</div>
|
||||
<div className="mt-1 font-mono text-xs text-gray-500">
|
||||
#
|
||||
{red.toString(16).padStart(2, '0')}
|
||||
{green.toString(16).padStart(2, '0')}
|
||||
{blue.toString(16).padStart(2, '0')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const MultipleSliders: Story = {
|
||||
render: () => <MultipleSlidersDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <SliderDemo {...args} />,
|
||||
args: {
|
||||
value: 50,
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
disabled: false,
|
||||
},
|
||||
}
|
||||
43
web/app/components/base/slider/index.tsx
Normal file
43
web/app/components/base/slider/index.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import ReactSlider from 'react-slider'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import './style.css'
|
||||
|
||||
type ISliderProps = {
|
||||
className?: string
|
||||
thumbClassName?: string
|
||||
trackClassName?: string
|
||||
value: number
|
||||
max?: number
|
||||
min?: number
|
||||
step?: number
|
||||
disabled?: boolean
|
||||
onChange: (value: number) => void
|
||||
}
|
||||
|
||||
const Slider: React.FC<ISliderProps> = ({
|
||||
className,
|
||||
thumbClassName,
|
||||
trackClassName,
|
||||
max,
|
||||
min,
|
||||
step,
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
}) => {
|
||||
return (
|
||||
<ReactSlider
|
||||
disabled={disabled}
|
||||
value={Number.isNaN(value) ? 0 : value}
|
||||
min={min || 0}
|
||||
max={max || 100}
|
||||
step={step || 1}
|
||||
className={cn('slider relative', className)}
|
||||
thumbClassName={cn('absolute top-[-9px] h-5 w-2 rounded-[3px] border-[0.5px] border-components-slider-knob-border bg-components-slider-knob shadow-sm focus:outline-none', !disabled && 'cursor-pointer', thumbClassName)}
|
||||
trackClassName={cn('h-0.5 rounded-full', 'slider-track', trackClassName)}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Slider
|
||||
11
web/app/components/base/slider/style.css
Normal file
11
web/app/components/base/slider/style.css
Normal file
@@ -0,0 +1,11 @@
|
||||
.slider.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
background-color: var(--color-components-slider-range);
|
||||
}
|
||||
|
||||
.slider-track-1 {
|
||||
background-color: var(--color-components-slider-track);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Slider } from '../index'
|
||||
|
||||
describe('Slider', () => {
|
||||
const getSliderInput = () => screen.getByLabelText('Value')
|
||||
|
||||
it('should render with correct default ARIA limits and current value', () => {
|
||||
render(<Slider value={50} onValueChange={vi.fn()} aria-label="Value" />)
|
||||
|
||||
const slider = getSliderInput()
|
||||
expect(slider).toHaveAttribute('min', '0')
|
||||
expect(slider).toHaveAttribute('max', '100')
|
||||
expect(slider).toHaveAttribute('aria-valuenow', '50')
|
||||
})
|
||||
|
||||
it('should apply custom min, max, and step values', () => {
|
||||
render(<Slider value={10} min={5} max={20} step={5} onValueChange={vi.fn()} aria-label="Value" />)
|
||||
|
||||
const slider = getSliderInput()
|
||||
expect(slider).toHaveAttribute('min', '5')
|
||||
expect(slider).toHaveAttribute('max', '20')
|
||||
expect(slider).toHaveAttribute('aria-valuenow', '10')
|
||||
})
|
||||
|
||||
it('should clamp non-finite values to min', () => {
|
||||
render(<Slider value={Number.NaN} min={5} onValueChange={vi.fn()} aria-label="Value" />)
|
||||
|
||||
expect(getSliderInput()).toHaveAttribute('aria-valuenow', '5')
|
||||
})
|
||||
|
||||
it('should call onValueChange when arrow keys are pressed', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
render(<Slider value={20} onValueChange={onValueChange} aria-label="Value" />)
|
||||
|
||||
const slider = getSliderInput()
|
||||
|
||||
await act(async () => {
|
||||
slider.focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
})
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledTimes(1)
|
||||
expect(onValueChange).toHaveBeenLastCalledWith(21, expect.anything())
|
||||
})
|
||||
|
||||
it('should not trigger onValueChange when disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onValueChange = vi.fn()
|
||||
render(<Slider value={20} onValueChange={onValueChange} disabled aria-label="Value" />)
|
||||
|
||||
const slider = getSliderInput()
|
||||
|
||||
expect(slider).toBeDisabled()
|
||||
|
||||
await act(async () => {
|
||||
slider.focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
})
|
||||
|
||||
expect(onValueChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should apply custom class names on root', () => {
|
||||
const { container } = render(<Slider value={10} onValueChange={vi.fn()} className="outer-test" aria-label="Value" />)
|
||||
|
||||
const sliderWrapper = container.querySelector('.outer-test')
|
||||
expect(sliderWrapper).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,92 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import type * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Slider } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base UI/Data Entry/Slider',
|
||||
component: Slider,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Single-value horizontal slider built on Base UI.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
value: {
|
||||
control: 'number',
|
||||
},
|
||||
min: {
|
||||
control: 'number',
|
||||
},
|
||||
max: {
|
||||
control: 'number',
|
||||
},
|
||||
step: {
|
||||
control: 'number',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Slider>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
function SliderDemo({
|
||||
value: initialValue = 50,
|
||||
defaultValue: _defaultValue,
|
||||
...args
|
||||
}: React.ComponentProps<typeof Slider>) {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
|
||||
return (
|
||||
<div className="w-[320px] space-y-3">
|
||||
<Slider
|
||||
{...args}
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
aria-label="Demo slider"
|
||||
/>
|
||||
<div className="text-center text-text-secondary system-sm-medium">
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
render: args => <SliderDemo {...args} />,
|
||||
args: {
|
||||
value: 50,
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
},
|
||||
}
|
||||
|
||||
export const Decimal: Story = {
|
||||
render: args => <SliderDemo {...args} />,
|
||||
args: {
|
||||
value: 0.5,
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.1,
|
||||
},
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: args => <SliderDemo {...args} />,
|
||||
args: {
|
||||
value: 75,
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
disabled: true,
|
||||
},
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Slider as BaseSlider } from '@base-ui/react/slider'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type SliderRootProps = BaseSlider.Root.Props<number>
|
||||
type SliderThumbProps = BaseSlider.Thumb.Props
|
||||
|
||||
type SliderBaseProps = Pick<
|
||||
SliderRootProps,
|
||||
'onValueChange' | 'min' | 'max' | 'step' | 'disabled' | 'name'
|
||||
> & Pick<SliderThumbProps, 'aria-label' | 'aria-labelledby'> & {
|
||||
className?: string
|
||||
}
|
||||
|
||||
type ControlledSliderProps = SliderBaseProps & {
|
||||
value: number
|
||||
defaultValue?: never
|
||||
}
|
||||
|
||||
type UncontrolledSliderProps = SliderBaseProps & {
|
||||
value?: never
|
||||
defaultValue?: number
|
||||
}
|
||||
|
||||
export type SliderProps = ControlledSliderProps | UncontrolledSliderProps
|
||||
|
||||
const sliderRootClassName = 'group/slider relative inline-flex w-full data-[disabled]:opacity-30'
|
||||
const sliderControlClassName = cn(
|
||||
'relative flex h-5 w-full touch-none select-none items-center',
|
||||
'data-[disabled]:cursor-not-allowed',
|
||||
)
|
||||
const sliderTrackClassName = cn(
|
||||
'relative h-1 w-full overflow-hidden rounded-full',
|
||||
'bg-[var(--slider-track,var(--color-components-slider-track))]',
|
||||
)
|
||||
const sliderIndicatorClassName = cn(
|
||||
'h-full rounded-full',
|
||||
'bg-[var(--slider-range,var(--color-components-slider-range))]',
|
||||
)
|
||||
const sliderThumbClassName = cn(
|
||||
'block h-5 w-2 shrink-0 rounded-[3px] border-[0.5px]',
|
||||
'border-[var(--slider-knob-border,var(--color-components-slider-knob-border))]',
|
||||
'bg-[var(--slider-knob,var(--color-components-slider-knob))] shadow-sm',
|
||||
'transition-[background-color,border-color,box-shadow,opacity] motion-reduce:transition-none',
|
||||
'hover:bg-[var(--slider-knob-hover,var(--color-components-slider-knob-hover))]',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-components-slider-knob-border-hover focus-visible:ring-offset-0',
|
||||
'active:shadow-md',
|
||||
'group-data-[disabled]/slider:bg-[var(--slider-knob-disabled,var(--color-components-slider-knob-disabled))]',
|
||||
'group-data-[disabled]/slider:border-[var(--slider-knob-border,var(--color-components-slider-knob-border))]',
|
||||
'group-data-[disabled]/slider:shadow-none',
|
||||
)
|
||||
|
||||
const getSafeValue = (value: number | undefined, min: number) => {
|
||||
if (value === undefined)
|
||||
return undefined
|
||||
|
||||
return Number.isFinite(value) ? value : min
|
||||
}
|
||||
|
||||
export function Slider({
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
min = 0,
|
||||
max = 100,
|
||||
step = 1,
|
||||
disabled = false,
|
||||
name,
|
||||
className,
|
||||
'aria-label': ariaLabel,
|
||||
'aria-labelledby': ariaLabelledby,
|
||||
}: SliderProps) {
|
||||
return (
|
||||
<BaseSlider.Root
|
||||
value={getSafeValue(value, min)}
|
||||
defaultValue={getSafeValue(defaultValue, min)}
|
||||
onValueChange={onValueChange}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
disabled={disabled}
|
||||
name={name}
|
||||
thumbAlignment="edge"
|
||||
className={cn(sliderRootClassName, className)}
|
||||
>
|
||||
<BaseSlider.Control className={sliderControlClassName}>
|
||||
<BaseSlider.Track className={sliderTrackClassName}>
|
||||
<BaseSlider.Indicator className={sliderIndicatorClassName} />
|
||||
</BaseSlider.Track>
|
||||
<BaseSlider.Thumb
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledby}
|
||||
className={sliderThumbClassName}
|
||||
/>
|
||||
</BaseSlider.Control>
|
||||
</BaseSlider.Root>
|
||||
)
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import PartnerStackCookieRecorder from '../cookie-recorder'
|
||||
|
||||
let isCloudEdition = true
|
||||
|
||||
const saveOrUpdate = vi.fn()
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
get IS_CLOUD_EDITION() {
|
||||
return isCloudEdition
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../use-ps-info', () => ({
|
||||
default: () => ({
|
||||
saveOrUpdate,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('PartnerStackCookieRecorder', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isCloudEdition = true
|
||||
})
|
||||
|
||||
it('should call saveOrUpdate once on mount when running in cloud edition', () => {
|
||||
render(<PartnerStackCookieRecorder />)
|
||||
|
||||
expect(saveOrUpdate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not call saveOrUpdate when not running in cloud edition', () => {
|
||||
isCloudEdition = false
|
||||
|
||||
render(<PartnerStackCookieRecorder />)
|
||||
|
||||
expect(saveOrUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render null', () => {
|
||||
const { container } = render(<PartnerStackCookieRecorder />)
|
||||
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -1,19 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import usePSInfo from './use-ps-info'
|
||||
|
||||
const PartnerStackCookieRecorder = () => {
|
||||
const { saveOrUpdate } = usePSInfo()
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_CLOUD_EDITION)
|
||||
return
|
||||
saveOrUpdate()
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default PartnerStackCookieRecorder
|
||||
@@ -24,7 +24,7 @@ const usePSInfo = () => {
|
||||
}] = useBoolean(false)
|
||||
const { mutateAsync } = useBindPartnerStackInfo()
|
||||
// Save to top domain. cloud.dify.ai => .dify.ai
|
||||
const domain = globalThis.location?.hostname.replace('cloud', '')
|
||||
const domain = globalThis.location.hostname.replace('cloud', '')
|
||||
|
||||
const saveOrUpdate = useCallback(() => {
|
||||
if (!psPartnerKey || !psClickId)
|
||||
@@ -39,7 +39,7 @@ const usePSInfo = () => {
|
||||
path: '/',
|
||||
domain,
|
||||
})
|
||||
}, [psPartnerKey, psClickId, isPSChanged, domain])
|
||||
}, [psPartnerKey, psClickId, isPSChanged])
|
||||
|
||||
const bind = useCallback(async () => {
|
||||
if (psPartnerKey && psClickId && !hasBind) {
|
||||
@@ -59,7 +59,7 @@ const usePSInfo = () => {
|
||||
Cookies.remove(PARTNER_STACK_CONFIG.cookieName, { path: '/', domain })
|
||||
setBind()
|
||||
}
|
||||
}, [psPartnerKey, psClickId, hasBind, domain, setBind, mutateAsync])
|
||||
}, [psPartnerKey, psClickId, mutateAsync, hasBind, setBind])
|
||||
return {
|
||||
psPartnerKey,
|
||||
psClickId,
|
||||
|
||||
@@ -264,7 +264,7 @@ describe('UrlInput', () => {
|
||||
|
||||
render(<UrlInput {...props} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: longUrl } })
|
||||
await userEvent.type(input, longUrl)
|
||||
|
||||
expect(input).toHaveValue(longUrl)
|
||||
})
|
||||
@@ -275,7 +275,7 @@ describe('UrlInput', () => {
|
||||
|
||||
render(<UrlInput {...props} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: unicodeUrl } })
|
||||
await userEvent.type(input, unicodeUrl)
|
||||
|
||||
expect(input).toHaveValue(unicodeUrl)
|
||||
})
|
||||
@@ -285,7 +285,7 @@ describe('UrlInput', () => {
|
||||
|
||||
render(<UrlInput {...props} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'https://rapid.com' } })
|
||||
await userEvent.type(input, 'https://rapid.com', { delay: 1 })
|
||||
|
||||
expect(input).toHaveValue('https://rapid.com')
|
||||
})
|
||||
@@ -297,7 +297,7 @@ describe('UrlInput', () => {
|
||||
|
||||
render(<UrlInput {...props} />)
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'https://enter.com' } })
|
||||
await userEvent.type(input, 'https://enter.com')
|
||||
|
||||
// Focus button and press enter
|
||||
const button = screen.getByRole('button', { name: /run/i })
|
||||
|
||||
@@ -157,7 +157,7 @@ describe('useDatasetCardState', () => {
|
||||
expect(result.current.modalState.showRenameModal).toBe(false)
|
||||
})
|
||||
|
||||
it('should close confirm delete modal when closeConfirmDelete is called', async () => {
|
||||
it('should close confirm delete modal when closeConfirmDelete is called', () => {
|
||||
const dataset = createMockDataset()
|
||||
const { result } = renderHook(() =>
|
||||
useDatasetCardState({ dataset, onSuccess: vi.fn() }),
|
||||
@@ -168,7 +168,7 @@ describe('useDatasetCardState', () => {
|
||||
result.current.detectIsUsedByApp()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
waitFor(() => {
|
||||
expect(result.current.modalState.showConfirmDelete).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ describe('IndexMethod', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const getKeywordSlider = () => screen.getByLabelText('datasetSettings.form.numberOfKeywords')
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<IndexMethod {...defaultProps} />)
|
||||
@@ -125,7 +123,8 @@ describe('IndexMethod', () => {
|
||||
describe('KeywordNumber', () => {
|
||||
it('should render KeywordNumber component inside Economy option', () => {
|
||||
render(<IndexMethod {...defaultProps} />)
|
||||
expect(getKeywordSlider()).toBeInTheDocument()
|
||||
// KeywordNumber has a slider
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass keywordNumber to KeywordNumber component', () => {
|
||||
|
||||
@@ -11,8 +11,6 @@ describe('KeyWordNumber', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const getSlider = () => screen.getByLabelText('datasetSettings.form.numberOfKeywords')
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
@@ -33,7 +31,8 @@ describe('KeyWordNumber', () => {
|
||||
|
||||
it('should render slider', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
expect(getSlider()).toBeInTheDocument()
|
||||
// Slider has a slider role
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render input number field', () => {
|
||||
@@ -62,7 +61,7 @@ describe('KeyWordNumber', () => {
|
||||
|
||||
it('should pass correct value to slider', () => {
|
||||
render(<KeyWordNumber {...defaultProps} keywordNumber={30} />)
|
||||
const slider = getSlider()
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('aria-valuenow', '30')
|
||||
})
|
||||
})
|
||||
@@ -72,7 +71,8 @@ describe('KeyWordNumber', () => {
|
||||
const handleChange = vi.fn()
|
||||
render(<KeyWordNumber {...defaultProps} onKeywordNumberChange={handleChange} />)
|
||||
|
||||
const slider = getSlider()
|
||||
const slider = screen.getByRole('slider')
|
||||
// Verify slider is rendered and interactive
|
||||
expect(slider).toBeInTheDocument()
|
||||
expect(slider).not.toBeDisabled()
|
||||
})
|
||||
@@ -109,14 +109,14 @@ describe('KeyWordNumber', () => {
|
||||
describe('Slider Configuration', () => {
|
||||
it('should have max value of 50', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
const slider = getSlider()
|
||||
expect(slider).toHaveAttribute('max', '50')
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('aria-valuemax', '50')
|
||||
})
|
||||
|
||||
it('should have min value of 0', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
const slider = getSlider()
|
||||
expect(slider).toHaveAttribute('min', '0')
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('aria-valuemin', '0')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -162,7 +162,7 @@ describe('KeyWordNumber', () => {
|
||||
describe('Accessibility', () => {
|
||||
it('should have accessible slider', () => {
|
||||
render(<KeyWordNumber {...defaultProps} />)
|
||||
const slider = getSlider()
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Slider from '@/app/components/base/slider'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import {
|
||||
NumberField,
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
} from '@/app/components/base/ui/number-field'
|
||||
import { Slider } from '@/app/components/base/ui/slider'
|
||||
|
||||
const MIN_KEYWORD_NUMBER = 0
|
||||
const MAX_KEYWORD_NUMBER = 50
|
||||
@@ -47,8 +47,7 @@ const KeyWordNumber = ({
|
||||
value={keywordNumber}
|
||||
min={MIN_KEYWORD_NUMBER}
|
||||
max={MAX_KEYWORD_NUMBER}
|
||||
onValueChange={onKeywordNumberChange}
|
||||
aria-label={t('form.numberOfKeywords', { ns: 'datasetSettings' })}
|
||||
onChange={onKeywordNumberChange}
|
||||
/>
|
||||
<NumberField
|
||||
className="w-12 shrink-0"
|
||||
|
||||
@@ -11,9 +11,9 @@ vi.mock('../../hooks', () => ({
|
||||
useLanguage: () => 'en_US',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/slider', () => ({
|
||||
Slider: ({ onValueChange }: { onValueChange: (v: number) => void }) => (
|
||||
<button onClick={() => onValueChange(2)} data-testid="slider-btn">Slide 2</button>
|
||||
vi.mock('@/app/components/base/slider', () => ({
|
||||
default: ({ onChange }: { onChange: (v: number) => void }) => (
|
||||
<button onClick={() => onChange(2)} data-testid="slider-btn">Slide 2</button>
|
||||
),
|
||||
}))
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PromptEditor from '@/app/components/base/prompt-editor'
|
||||
import Radio from '@/app/components/base/radio'
|
||||
import Slider from '@/app/components/base/slider'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import TagInput from '@/app/components/base/tag-input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
|
||||
import { Slider } from '@/app/components/base/ui/slider'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { cn } from '@/utils/classnames'
|
||||
@@ -78,7 +78,6 @@ function ParameterItem({
|
||||
}
|
||||
|
||||
const renderValue = value ?? localValue ?? getDefaultValue()
|
||||
const sliderLabel = parameterRule.label[language] || parameterRule.label.en_US
|
||||
|
||||
const handleInputChange = (newValue: ParameterValue) => {
|
||||
setLocalValue(newValue)
|
||||
@@ -171,8 +170,7 @@ function ParameterItem({
|
||||
min={parameterRule.min}
|
||||
max={parameterRule.max}
|
||||
step={step}
|
||||
onValueChange={handleSlideChange}
|
||||
aria-label={sliderLabel}
|
||||
onChange={handleSlideChange}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
@@ -199,8 +197,7 @@ function ParameterItem({
|
||||
min={parameterRule.min}
|
||||
max={parameterRule.max}
|
||||
step={0.1}
|
||||
onValueChange={handleSlideChange}
|
||||
aria-label={sliderLabel}
|
||||
onChange={handleSlideChange}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
@@ -340,9 +337,9 @@ function ParameterItem({
|
||||
}
|
||||
<div
|
||||
className="mr-0.5 truncate text-text-secondary system-xs-regular"
|
||||
title={sliderLabel}
|
||||
title={parameterRule.label[language] || parameterRule.label.en_US}
|
||||
>
|
||||
{sliderLabel}
|
||||
{parameterRule.label[language] || parameterRule.label.en_US}
|
||||
</div>
|
||||
{
|
||||
parameterRule.help && (
|
||||
|
||||
@@ -101,7 +101,6 @@ const createHumanInput = (overrides: Partial<HumanInputFormData> = {}): HumanInp
|
||||
describe('workflow-stream-handlers helpers', () => {
|
||||
it('should update tracing, result text, and human input state', () => {
|
||||
const parallelTrace = createTrace({
|
||||
id: 'parallel-trace-1',
|
||||
node_id: 'parallel-node',
|
||||
execution_metadata: { parallel_id: 'parallel-1' },
|
||||
details: [[]],
|
||||
@@ -110,13 +109,11 @@ describe('workflow-stream-handlers helpers', () => {
|
||||
let workflowProcessData = appendParallelStart(undefined, parallelTrace)
|
||||
workflowProcessData = appendParallelNext(workflowProcessData, parallelTrace)
|
||||
workflowProcessData = finishParallelTrace(workflowProcessData, createTrace({
|
||||
id: 'parallel-trace-1',
|
||||
node_id: 'parallel-node',
|
||||
execution_metadata: { parallel_id: 'parallel-1' },
|
||||
error: 'failed',
|
||||
}))
|
||||
workflowProcessData = upsertWorkflowNode(workflowProcessData, createTrace({
|
||||
id: 'node-trace-1',
|
||||
node_id: 'node-1',
|
||||
execution_metadata: { parallel_id: 'parallel-2' },
|
||||
}))!
|
||||
@@ -163,129 +160,6 @@ describe('workflow-stream-handlers helpers', () => {
|
||||
expect(nextProcess.tracing[0]?.details).toEqual([[], []])
|
||||
})
|
||||
|
||||
it('should keep separate iteration and loop traces for repeated executions with different ids', () => {
|
||||
const process = createWorkflowProcess()
|
||||
process.tracing = [
|
||||
createTrace({
|
||||
id: 'iter-trace-1',
|
||||
node_id: 'iter-1',
|
||||
details: [[]],
|
||||
}),
|
||||
createTrace({
|
||||
id: 'iter-trace-2',
|
||||
node_id: 'iter-1',
|
||||
details: [[]],
|
||||
}),
|
||||
createTrace({
|
||||
id: 'loop-trace-1',
|
||||
node_id: 'loop-1',
|
||||
details: [[]],
|
||||
}),
|
||||
createTrace({
|
||||
id: 'loop-trace-2',
|
||||
node_id: 'loop-1',
|
||||
details: [[]],
|
||||
}),
|
||||
]
|
||||
|
||||
const iterNextProcess = appendParallelNext(process, createTrace({
|
||||
id: 'iter-trace-2',
|
||||
node_id: 'iter-1',
|
||||
}))
|
||||
const iterFinishedProcess = finishParallelTrace(iterNextProcess, createTrace({
|
||||
id: 'iter-trace-2',
|
||||
node_id: 'iter-1',
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
details: undefined,
|
||||
}))
|
||||
const loopNextProcess = appendParallelNext(iterFinishedProcess, createTrace({
|
||||
id: 'loop-trace-2',
|
||||
node_id: 'loop-1',
|
||||
}))
|
||||
const loopFinishedProcess = finishParallelTrace(loopNextProcess, createTrace({
|
||||
id: 'loop-trace-2',
|
||||
node_id: 'loop-1',
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
details: undefined,
|
||||
}))
|
||||
|
||||
expect(loopFinishedProcess.tracing[0]).toEqual(expect.objectContaining({
|
||||
id: 'iter-trace-1',
|
||||
details: [[]],
|
||||
status: NodeRunningStatus.Running,
|
||||
}))
|
||||
expect(loopFinishedProcess.tracing[1]).toEqual(expect.objectContaining({
|
||||
id: 'iter-trace-2',
|
||||
details: [[], []],
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
}))
|
||||
expect(loopFinishedProcess.tracing[2]).toEqual(expect.objectContaining({
|
||||
id: 'loop-trace-1',
|
||||
details: [[]],
|
||||
status: NodeRunningStatus.Running,
|
||||
}))
|
||||
expect(loopFinishedProcess.tracing[3]).toEqual(expect.objectContaining({
|
||||
id: 'loop-trace-2',
|
||||
details: [[], []],
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should append a new top-level trace when the same node starts with a different execution id', () => {
|
||||
const process = createWorkflowProcess()
|
||||
process.tracing = [
|
||||
createTrace({
|
||||
id: 'trace-1',
|
||||
node_id: 'node-1',
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
}),
|
||||
]
|
||||
|
||||
const updatedProcess = upsertWorkflowNode(process, createTrace({
|
||||
id: 'trace-2',
|
||||
node_id: 'node-1',
|
||||
}))!
|
||||
|
||||
expect(updatedProcess.tracing).toHaveLength(2)
|
||||
expect(updatedProcess.tracing[1]).toEqual(expect.objectContaining({
|
||||
id: 'trace-2',
|
||||
node_id: 'node-1',
|
||||
status: NodeRunningStatus.Running,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should finish the matching top-level trace when the same node runs again with a new execution id', () => {
|
||||
const process = createWorkflowProcess()
|
||||
process.tracing = [
|
||||
createTrace({
|
||||
id: 'trace-1',
|
||||
node_id: 'node-1',
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
}),
|
||||
createTrace({
|
||||
id: 'trace-2',
|
||||
node_id: 'node-1',
|
||||
status: NodeRunningStatus.Running,
|
||||
}),
|
||||
]
|
||||
|
||||
const updatedProcess = finishWorkflowNode(process, createTrace({
|
||||
id: 'trace-2',
|
||||
node_id: 'node-1',
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
}))!
|
||||
|
||||
expect(updatedProcess.tracing).toHaveLength(2)
|
||||
expect(updatedProcess.tracing[0]).toEqual(expect.objectContaining({
|
||||
id: 'trace-1',
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
}))
|
||||
expect(updatedProcess.tracing[1]).toEqual(expect.objectContaining({
|
||||
id: 'trace-2',
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should leave tracing unchanged when a parallel next event has no matching trace', () => {
|
||||
const process = createWorkflowProcess()
|
||||
process.tracing = [
|
||||
@@ -297,7 +171,6 @@ describe('workflow-stream-handlers helpers', () => {
|
||||
]
|
||||
|
||||
const nextProcess = appendParallelNext(process, createTrace({
|
||||
id: 'trace-missing',
|
||||
node_id: 'missing-node',
|
||||
execution_metadata: { parallel_id: 'parallel-2' },
|
||||
}))
|
||||
@@ -355,7 +228,6 @@ describe('workflow-stream-handlers helpers', () => {
|
||||
},
|
||||
}))
|
||||
const notFinished = finishParallelTrace(process, createTrace({
|
||||
id: 'trace-missing',
|
||||
node_id: 'missing',
|
||||
execution_metadata: {
|
||||
parallel_id: 'parallel-missing',
|
||||
@@ -371,7 +243,6 @@ describe('workflow-stream-handlers helpers', () => {
|
||||
loop_id: 'loop-1',
|
||||
}))
|
||||
const unmatchedFinish = finishWorkflowNode(process, createTrace({
|
||||
id: 'trace-missing',
|
||||
node_id: 'missing',
|
||||
execution_metadata: {
|
||||
parallel_id: 'missing',
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { HumanInputFormTimeoutData, NodeTracing, WorkflowFinishedResponse }
|
||||
import { produce } from 'immer'
|
||||
import { getFilesInLogs } from '@/app/components/base/file-uploader/utils'
|
||||
import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { upsertTopLevelTracingNodeOnStart } from '@/app/components/workflow/utils/top-level-tracing'
|
||||
import { sseGet } from '@/service/base'
|
||||
|
||||
type Notify = (payload: { type: 'error' | 'warning', message: string }) => void
|
||||
@@ -50,15 +49,6 @@ const matchParallelTrace = (trace: WorkflowProcess['tracing'][number], data: Nod
|
||||
|| trace.parallel_id === data.execution_metadata?.parallel_id)
|
||||
}
|
||||
|
||||
const findParallelTraceIndex = (tracing: WorkflowProcess['tracing'], data: NodeTracing) => {
|
||||
return tracing.findIndex((trace) => {
|
||||
if (trace.id && data.id)
|
||||
return trace.id === data.id
|
||||
|
||||
return matchParallelTrace(trace, data)
|
||||
})
|
||||
}
|
||||
|
||||
const ensureParallelTraceDetails = (details?: NodeTracing['details']) => {
|
||||
return details?.length ? details : [[]]
|
||||
}
|
||||
@@ -78,8 +68,7 @@ const appendParallelStart = (current: WorkflowProcess | undefined, data: NodeTra
|
||||
const appendParallelNext = (current: WorkflowProcess | undefined, data: NodeTracing) => {
|
||||
return updateWorkflowProcess(current, (draft) => {
|
||||
draft.expand = true
|
||||
const traceIndex = findParallelTraceIndex(draft.tracing, data)
|
||||
const trace = draft.tracing[traceIndex]
|
||||
const trace = draft.tracing.find(item => matchParallelTrace(item, data))
|
||||
if (!trace)
|
||||
return
|
||||
|
||||
@@ -91,13 +80,10 @@ const appendParallelNext = (current: WorkflowProcess | undefined, data: NodeTrac
|
||||
const finishParallelTrace = (current: WorkflowProcess | undefined, data: NodeTracing) => {
|
||||
return updateWorkflowProcess(current, (draft) => {
|
||||
draft.expand = true
|
||||
const traceIndex = findParallelTraceIndex(draft.tracing, data)
|
||||
const traceIndex = draft.tracing.findIndex(item => matchParallelTrace(item, data))
|
||||
if (traceIndex > -1) {
|
||||
const currentTrace = draft.tracing[traceIndex]
|
||||
draft.tracing[traceIndex] = {
|
||||
...currentTrace,
|
||||
...data,
|
||||
details: data.details ?? currentTrace.details,
|
||||
expand: !!data.error,
|
||||
}
|
||||
}
|
||||
@@ -110,22 +96,17 @@ const upsertWorkflowNode = (current: WorkflowProcess | undefined, data: NodeTrac
|
||||
|
||||
return updateWorkflowProcess(current, (draft) => {
|
||||
draft.expand = true
|
||||
const currentIndex = draft.tracing.findIndex(item => item.node_id === data.node_id)
|
||||
const nextTrace = {
|
||||
...data,
|
||||
status: NodeRunningStatus.Running,
|
||||
expand: true,
|
||||
}
|
||||
|
||||
upsertTopLevelTracingNodeOnStart(draft.tracing, nextTrace)
|
||||
})
|
||||
}
|
||||
|
||||
const findWorkflowNodeTraceIndex = (tracing: WorkflowProcess['tracing'], data: NodeTracing) => {
|
||||
return tracing.findIndex((trace) => {
|
||||
if (trace.id && data.id)
|
||||
return trace.id === data.id
|
||||
|
||||
return matchParallelTrace(trace, data)
|
||||
if (currentIndex > -1)
|
||||
draft.tracing[currentIndex] = nextTrace
|
||||
else
|
||||
draft.tracing.push(nextTrace)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -134,7 +115,7 @@ const finishWorkflowNode = (current: WorkflowProcess | undefined, data: NodeTrac
|
||||
return current
|
||||
|
||||
return updateWorkflowProcess(current, (draft) => {
|
||||
const currentIndex = findWorkflowNodeTraceIndex(draft.tracing, data)
|
||||
const currentIndex = draft.tracing.findIndex(trace => matchParallelTrace(trace, data))
|
||||
if (currentIndex > -1) {
|
||||
draft.tracing[currentIndex] = {
|
||||
...(draft.tracing[currentIndex].extras
|
||||
|
||||
@@ -109,13 +109,13 @@ describe('useWorkflowAgentLog', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ id: 'trace-1', node_id: 'n1', execution_metadata: {} }],
|
||||
tracing: [{ node_id: 'n1', execution_metadata: {} }],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowAgentLog({
|
||||
data: { node_id: 'n1', node_execution_id: 'trace-1', message_id: 'm1' },
|
||||
data: { node_id: 'n1', message_id: 'm1' },
|
||||
} as AgentLogResponse)
|
||||
|
||||
const trace = store.getState().workflowRunningData!.tracing![0]
|
||||
@@ -128,7 +128,6 @@ describe('useWorkflowAgentLog', () => {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{
|
||||
id: 'trace-1',
|
||||
node_id: 'n1',
|
||||
execution_metadata: { agent_log: [{ message_id: 'm1', text: 'log1' }] },
|
||||
}],
|
||||
@@ -137,7 +136,7 @@ describe('useWorkflowAgentLog', () => {
|
||||
})
|
||||
|
||||
result.current.handleWorkflowAgentLog({
|
||||
data: { node_id: 'n1', node_execution_id: 'trace-1', message_id: 'm2' },
|
||||
data: { node_id: 'n1', message_id: 'm2' },
|
||||
} as AgentLogResponse)
|
||||
|
||||
expect(store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log).toHaveLength(2)
|
||||
@@ -148,7 +147,6 @@ describe('useWorkflowAgentLog', () => {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{
|
||||
id: 'trace-1',
|
||||
node_id: 'n1',
|
||||
execution_metadata: { agent_log: [{ message_id: 'm1', text: 'old' }] },
|
||||
}],
|
||||
@@ -157,7 +155,7 @@ describe('useWorkflowAgentLog', () => {
|
||||
})
|
||||
|
||||
result.current.handleWorkflowAgentLog({
|
||||
data: { node_id: 'n1', node_execution_id: 'trace-1', message_id: 'm1', text: 'new' },
|
||||
data: { node_id: 'n1', message_id: 'm1', text: 'new' },
|
||||
} as unknown as AgentLogResponse)
|
||||
|
||||
const log = store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log!
|
||||
@@ -169,39 +167,17 @@ describe('useWorkflowAgentLog', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [{ id: 'trace-1', node_id: 'n1' }],
|
||||
tracing: [{ node_id: 'n1' }],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowAgentLog({
|
||||
data: { node_id: 'n1', node_execution_id: 'trace-1', message_id: 'm1' },
|
||||
data: { node_id: 'n1', message_id: 'm1' },
|
||||
} as AgentLogResponse)
|
||||
|
||||
expect(store.getState().workflowRunningData!.tracing![0].execution_metadata!.agent_log).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should attach the log to the matching execution id when a node runs multiple times', () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowAgentLog(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [
|
||||
{ id: 'trace-1', node_id: 'n1', execution_metadata: {} },
|
||||
{ id: 'trace-2', node_id: 'n1', execution_metadata: {} },
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
result.current.handleWorkflowAgentLog({
|
||||
data: { node_id: 'n1', node_execution_id: 'trace-2', message_id: 'm2' },
|
||||
} as AgentLogResponse)
|
||||
|
||||
const tracing = store.getState().workflowRunningData!.tracing!
|
||||
expect(tracing[0].execution_metadata!.agent_log).toBeUndefined()
|
||||
expect(tracing[1].execution_metadata!.agent_log).toHaveLength(1)
|
||||
expect(tracing[1].execution_metadata!.agent_log![0].message_id).toBe('m2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowNodeHumanInputFormFilled', () => {
|
||||
|
||||
@@ -109,7 +109,7 @@ describe('useWorkflowNodeStarted', () => {
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeStarted(
|
||||
{ data: { id: 'trace-n1', node_id: 'n1' } } as NodeStartedResponse,
|
||||
{ data: { node_id: 'n1' } } as NodeStartedResponse,
|
||||
containerParams,
|
||||
)
|
||||
})
|
||||
@@ -138,7 +138,7 @@ describe('useWorkflowNodeStarted', () => {
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeStarted(
|
||||
{ data: { id: 'trace-n2', node_id: 'n2' } } as NodeStartedResponse,
|
||||
{ data: { node_id: 'n2' } } as NodeStartedResponse,
|
||||
containerParams,
|
||||
)
|
||||
})
|
||||
@@ -157,8 +157,8 @@ describe('useWorkflowNodeStarted', () => {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [
|
||||
{ id: 'trace-0', node_id: 'n0', status: NodeRunningStatus.Succeeded },
|
||||
{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
|
||||
{ node_id: 'n0', status: NodeRunningStatus.Succeeded },
|
||||
{ node_id: 'n1', status: NodeRunningStatus.Succeeded },
|
||||
],
|
||||
}),
|
||||
},
|
||||
@@ -166,7 +166,7 @@ describe('useWorkflowNodeStarted', () => {
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeStarted(
|
||||
{ data: { id: 'trace-1', node_id: 'n1' } } as NodeStartedResponse,
|
||||
{ data: { node_id: 'n1' } } as NodeStartedResponse,
|
||||
containerParams,
|
||||
)
|
||||
})
|
||||
@@ -175,32 +175,6 @@ describe('useWorkflowNodeStarted', () => {
|
||||
expect(tracing).toHaveLength(2)
|
||||
expect(tracing[1].status).toBe(NodeRunningStatus.Running)
|
||||
})
|
||||
|
||||
it('should append a new tracing entry when the same node starts a new execution id', () => {
|
||||
const { result, store } = renderViewportHook(() => useWorkflowNodeStarted(), {
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({
|
||||
tracing: [
|
||||
{ id: 'trace-0', node_id: 'n0', status: NodeRunningStatus.Succeeded },
|
||||
{ id: 'trace-1', node_id: 'n1', status: NodeRunningStatus.Succeeded },
|
||||
],
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowNodeStarted(
|
||||
{ data: { id: 'trace-2', node_id: 'n1' } } as NodeStartedResponse,
|
||||
containerParams,
|
||||
)
|
||||
})
|
||||
|
||||
const tracing = store.getState().workflowRunningData!.tracing!
|
||||
expect(tracing).toHaveLength(3)
|
||||
expect(tracing[2].id).toBe('trace-2')
|
||||
expect(tracing[2].node_id).toBe('n1')
|
||||
expect(tracing[2].status).toBe(NodeRunningStatus.Running)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useWorkflowNodeIterationStarted', () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ export const useWorkflowAgentLog = () => {
|
||||
} = workflowStore.getState()
|
||||
|
||||
setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
|
||||
const currentIndex = draft.tracing!.findIndex(item => item.id === data.node_execution_id)
|
||||
const currentIndex = draft.tracing!.findIndex(item => item.node_id === data.node_id)
|
||||
if (currentIndex > -1) {
|
||||
const current = draft.tracing![currentIndex]
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@ export const useWorkflowNodeStarted = () => {
|
||||
transform,
|
||||
} = store.getState()
|
||||
const nodes = getNodes()
|
||||
const currentIndex = workflowRunningData?.tracing?.findIndex(item => item.id === data.id)
|
||||
if (currentIndex !== undefined && currentIndex > -1) {
|
||||
const currentIndex = workflowRunningData?.tracing?.findIndex(item => item.node_id === data.node_id)
|
||||
if (currentIndex && currentIndex > -1) {
|
||||
setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
|
||||
draft.tracing![currentIndex] = {
|
||||
...data,
|
||||
|
||||
@@ -145,7 +145,7 @@ describe('AgentStrategy', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByLabelText('Count')).toBeInTheDocument()
|
||||
expect(screen.getByRole('slider')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Agent } from '@/app/components/base/icons/src/vender/workflow'
|
||||
import ListEmpty from '@/app/components/base/list-empty'
|
||||
import Slider from '@/app/components/base/slider'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldControls,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
} from '@/app/components/base/ui/number-field'
|
||||
import { Slider } from '@/app/components/base/ui/slider'
|
||||
import { FormTypeEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form'
|
||||
@@ -147,11 +147,10 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
|
||||
<div className="flex w-[200px] items-center gap-3">
|
||||
<Slider
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
onChange={onChange}
|
||||
className="w-full"
|
||||
min={def.min}
|
||||
max={def.max}
|
||||
aria-label={renderI18nObject(def.label)}
|
||||
/>
|
||||
<NumberField
|
||||
value={value}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { FC } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { Slider } from '@/app/components/base/ui/slider'
|
||||
import Slider from '@/app/components/base/slider'
|
||||
|
||||
export type InputNumberWithSliderProps = {
|
||||
value: number
|
||||
@@ -22,7 +22,7 @@ const InputNumberWithSlider: FC<InputNumberWithSliderProps> = ({
|
||||
onChange,
|
||||
}) => {
|
||||
const handleBlur = useCallback(() => {
|
||||
if (value === undefined || value === null || Number.isNaN(value)) {
|
||||
if (value === undefined || value === null) {
|
||||
onChange(defaultValue)
|
||||
return
|
||||
}
|
||||
@@ -57,9 +57,8 @@ const InputNumberWithSlider: FC<InputNumberWithSliderProps> = ({
|
||||
min={min}
|
||||
max={max}
|
||||
step={1}
|
||||
onValueChange={onChange}
|
||||
onChange={onChange}
|
||||
disabled={readonly}
|
||||
aria-label="Number input slider"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,8 +6,8 @@ import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Slider from '@/app/components/base/slider'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import { Slider } from '@/app/components/base/ui/slider'
|
||||
import Field from '@/app/components/workflow/nodes/_base/components/field'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { MemoryRole } from '../../../types'
|
||||
@@ -154,7 +154,7 @@ const MemoryConfig: FC<Props> = ({
|
||||
size="md"
|
||||
disabled={readonly}
|
||||
/>
|
||||
<div className="text-text-tertiary system-xs-medium-uppercase">{t(`${i18nPrefix}.windowSize`, { ns: 'workflow' })}</div>
|
||||
<div className="system-xs-medium-uppercase text-text-tertiary">{t(`${i18nPrefix}.windowSize`, { ns: 'workflow' })}</div>
|
||||
</div>
|
||||
<div className="flex h-8 items-center space-x-2">
|
||||
<Slider
|
||||
@@ -163,9 +163,8 @@ const MemoryConfig: FC<Props> = ({
|
||||
min={WINDOW_SIZE_MIN}
|
||||
max={WINDOW_SIZE_MAX}
|
||||
step={1}
|
||||
onValueChange={handleWindowSizeChange}
|
||||
onChange={handleWindowSizeChange}
|
||||
disabled={readonly || !payload.window?.enabled}
|
||||
aria-label={t(`${i18nPrefix}.windowSize`, { ns: 'workflow' })}
|
||||
/>
|
||||
<Input
|
||||
value={(payload.window?.size || WINDOW_SIZE_DEFAULT) as number}
|
||||
|
||||
@@ -3,8 +3,8 @@ import type {
|
||||
} from '@/app/components/workflow/types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Slider from '@/app/components/base/slider'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import { Slider } from '@/app/components/base/ui/slider'
|
||||
import Split from '@/app/components/workflow/nodes/_base/components/split'
|
||||
import { useRetryConfig } from './hooks'
|
||||
import s from './style.module.css'
|
||||
@@ -70,10 +70,9 @@ const RetryOnPanel = ({
|
||||
<Slider
|
||||
className="mr-3 w-[108px]"
|
||||
value={retry_config?.max_retries || 3}
|
||||
onValueChange={handleMaxRetriesChange}
|
||||
onChange={handleMaxRetriesChange}
|
||||
min={1}
|
||||
max={10}
|
||||
aria-label={t('nodes.common.retry.maxRetries', { ns: 'workflow' })}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -92,10 +91,9 @@ const RetryOnPanel = ({
|
||||
<Slider
|
||||
className="mr-3 w-[108px]"
|
||||
value={retry_config?.retry_interval || 1000}
|
||||
onValueChange={handleRetryIntervalChange}
|
||||
onChange={handleRetryIntervalChange}
|
||||
min={100}
|
||||
max={5000}
|
||||
aria-label={t('nodes.common.retry.retryInterval', { ns: 'workflow' })}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
|
||||
@@ -5,8 +5,8 @@ import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Select from '@/app/components/base/select'
|
||||
import Slider from '@/app/components/base/slider'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import { Slider } from '@/app/components/base/ui/slider'
|
||||
import Field from '@/app/components/workflow/nodes/_base/components/field'
|
||||
import { ErrorHandleMode } from '@/app/components/workflow/types'
|
||||
import { MAX_PARALLEL_LIMIT } from '@/config'
|
||||
@@ -57,7 +57,7 @@ const Panel: FC<NodePanelProps<IterationNodeType>> = ({
|
||||
title={t(`${i18nPrefix}.input`, { ns: 'workflow' })}
|
||||
required
|
||||
operations={(
|
||||
<div className="flex h-[18px] items-center rounded-[5px] border border-divider-deep px-1 capitalize text-text-tertiary system-2xs-medium-uppercase">Array</div>
|
||||
<div className="system-2xs-medium-uppercase flex h-[18px] items-center rounded-[5px] border border-divider-deep px-1 capitalize text-text-tertiary">Array</div>
|
||||
)}
|
||||
>
|
||||
<VarReferencePicker
|
||||
@@ -76,7 +76,7 @@ const Panel: FC<NodePanelProps<IterationNodeType>> = ({
|
||||
title={t(`${i18nPrefix}.output`, { ns: 'workflow' })}
|
||||
required
|
||||
operations={(
|
||||
<div className="flex h-[18px] items-center rounded-[5px] border border-divider-deep px-1 capitalize text-text-tertiary system-2xs-medium-uppercase">Array</div>
|
||||
<div className="system-2xs-medium-uppercase flex h-[18px] items-center rounded-[5px] border border-divider-deep px-1 capitalize text-text-tertiary">Array</div>
|
||||
)}
|
||||
>
|
||||
<VarReferencePicker
|
||||
@@ -103,11 +103,10 @@ const Panel: FC<NodePanelProps<IterationNodeType>> = ({
|
||||
<Input type="number" wrapperClassName="w-18 mr-4 " max={MAX_PARALLEL_LIMIT} min={MIN_ITERATION_PARALLEL_NUM} value={inputs.parallel_nums} onChange={(e) => { changeParallelNums(Number(e.target.value)) }} />
|
||||
<Slider
|
||||
value={inputs.parallel_nums}
|
||||
onValueChange={changeParallelNums}
|
||||
onChange={changeParallelNums}
|
||||
max={MAX_PARALLEL_LIMIT}
|
||||
min={MIN_ITERATION_PARALLEL_NUM}
|
||||
className="mt-4 flex-1 shrink-0"
|
||||
aria-label={t(`${i18nPrefix}.MaxParallelismTitle`, { ns: 'workflow' })}
|
||||
className=" mt-4 flex-1 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('IndexMethod', () => {
|
||||
|
||||
fireEvent.change(container.querySelector('input') as HTMLInputElement, { target: { value: '7' } })
|
||||
|
||||
expect(onKeywordNumberChange).toHaveBeenCalledWith(7, expect.anything())
|
||||
expect(onKeywordNumberChange).toHaveBeenCalledWith(7)
|
||||
})
|
||||
|
||||
it('should disable keyword controls when readonly is enabled', () => {
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
HighQuality,
|
||||
} from '@/app/components/base/icons/src/vender/knowledge'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Slider from '@/app/components/base/slider'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Slider } from '@/app/components/base/ui/slider'
|
||||
import { Field } from '@/app/components/workflow/nodes/_base/components/layout'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import {
|
||||
@@ -94,7 +94,7 @@ const IndexMethod = ({
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="flex grow items-center">
|
||||
<div className="truncate text-text-secondary system-xs-medium">
|
||||
<div className="system-xs-medium truncate text-text-secondary">
|
||||
{t('form.numberOfKeywords', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
<Tooltip
|
||||
@@ -107,8 +107,7 @@ const IndexMethod = ({
|
||||
disabled={readonly}
|
||||
className="mr-3 w-24 shrink-0"
|
||||
value={keywordNumber}
|
||||
onValueChange={onKeywordNumberChange}
|
||||
aria-label={t('form.numberOfKeywords', { ns: 'datasetSettings' })}
|
||||
onChange={onKeywordNumberChange}
|
||||
/>
|
||||
<Input
|
||||
disabled={readonly}
|
||||
|
||||
@@ -93,11 +93,11 @@ describe('trigger-schedule components', () => {
|
||||
const onChange = vi.fn()
|
||||
render(<OnMinuteSelector value={15} onChange={onChange} />)
|
||||
|
||||
const slider = screen.getByLabelText('workflow.nodes.triggerSchedule.onMinute')
|
||||
const slider = screen.getByRole('slider')
|
||||
slider.focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(16, expect.objectContaining({ activeThumbIndex: 0 }))
|
||||
expect(onChange).toHaveBeenCalledWith(16, 0)
|
||||
})
|
||||
|
||||
it('should keep at least one weekday selected', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Slider } from '@/app/components/base/ui/slider'
|
||||
import Slider from '@/app/components/base/slider'
|
||||
|
||||
type OnMinuteSelectorProps = {
|
||||
value?: number
|
||||
@@ -27,8 +27,7 @@ const OnMinuteSelector = ({ value = 0, onChange }: OnMinuteSelectorProps) => {
|
||||
min={0}
|
||||
max={59}
|
||||
step={1}
|
||||
onValueChange={onChange}
|
||||
aria-label={t('nodes.triggerSchedule.onMinute', { ns: 'workflow' })}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
import { useHooksStore } from '../../hooks-store'
|
||||
import { useWorkflowStore } from '../../store'
|
||||
import { NodeRunningStatus, WorkflowRunningStatus } from '../../types'
|
||||
import { upsertTopLevelTracingNodeOnStart } from '../../utils/top-level-tracing'
|
||||
|
||||
type GetAbortController = (abortController: AbortController) => void
|
||||
type SendCallback = {
|
||||
@@ -509,13 +508,19 @@ export const useChat = (
|
||||
}
|
||||
},
|
||||
onNodeStarted: ({ data }) => {
|
||||
if (params.loop_id)
|
||||
return
|
||||
|
||||
upsertTopLevelTracingNodeOnStart(responseItem.workflowProcess!.tracing!, {
|
||||
...data,
|
||||
status: NodeRunningStatus.Running,
|
||||
})
|
||||
const currentIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.node_id === data.node_id)
|
||||
if (currentIndex > -1) {
|
||||
responseItem.workflowProcess!.tracing![currentIndex] = {
|
||||
...data,
|
||||
status: NodeRunningStatus.Running,
|
||||
}
|
||||
}
|
||||
else {
|
||||
responseItem.workflowProcess!.tracing!.push({
|
||||
...data,
|
||||
status: NodeRunningStatus.Running,
|
||||
})
|
||||
}
|
||||
updateCurrentQAOnTree({
|
||||
placeholderQuestionId,
|
||||
questionItem,
|
||||
@@ -534,9 +539,6 @@ export const useChat = (
|
||||
})
|
||||
},
|
||||
onNodeFinished: ({ data }) => {
|
||||
if (params.loop_id)
|
||||
return
|
||||
|
||||
const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.id === data.id)
|
||||
if (currentTracingIndex > -1) {
|
||||
responseItem.workflowProcess!.tracing[currentTracingIndex] = {
|
||||
@@ -552,7 +554,7 @@ export const useChat = (
|
||||
}
|
||||
},
|
||||
onAgentLog: ({ data }) => {
|
||||
const currentNodeIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.id === data.node_execution_id)
|
||||
const currentNodeIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.node_id === data.node_id)
|
||||
if (currentNodeIndex > -1) {
|
||||
const current = responseItem.workflowProcess!.tracing![currentNodeIndex]
|
||||
|
||||
@@ -778,7 +780,8 @@ export const useChat = (
|
||||
if (!responseItem.workflowProcess?.tracing)
|
||||
return
|
||||
const tracing = responseItem.workflowProcess.tracing
|
||||
const iterationIndex = tracing.findIndex(item => item.id === iterationFinishedData.id)!
|
||||
const iterationIndex = tracing.findIndex(item => item.node_id === iterationFinishedData.node_id
|
||||
&& (item.execution_metadata?.parallel_id === iterationFinishedData.execution_metadata?.parallel_id || item.parallel_id === iterationFinishedData.execution_metadata?.parallel_id))!
|
||||
if (iterationIndex > -1) {
|
||||
tracing[iterationIndex] = {
|
||||
...tracing[iterationIndex],
|
||||
@@ -795,10 +798,22 @@ export const useChat = (
|
||||
if (!responseItem.workflowProcess.tracing)
|
||||
responseItem.workflowProcess.tracing = []
|
||||
|
||||
upsertTopLevelTracingNodeOnStart(responseItem.workflowProcess.tracing, {
|
||||
...nodeStartedData,
|
||||
status: NodeRunningStatus.Running,
|
||||
})
|
||||
const currentIndex = responseItem.workflowProcess.tracing.findIndex(item => item.node_id === nodeStartedData.node_id)
|
||||
if (currentIndex > -1) {
|
||||
responseItem.workflowProcess.tracing[currentIndex] = {
|
||||
...nodeStartedData,
|
||||
status: NodeRunningStatus.Running,
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (nodeStartedData.iteration_id)
|
||||
return
|
||||
|
||||
responseItem.workflowProcess.tracing.push({
|
||||
...nodeStartedData,
|
||||
status: WorkflowRunningStatus.Running,
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
onNodeFinished: ({ data: nodeFinishedData }) => {
|
||||
@@ -809,9 +824,6 @@ export const useChat = (
|
||||
if (nodeFinishedData.iteration_id)
|
||||
return
|
||||
|
||||
if (nodeFinishedData.loop_id)
|
||||
return
|
||||
|
||||
const currentIndex = responseItem.workflowProcess.tracing.findIndex((item) => {
|
||||
if (!item.execution_metadata?.parallel_id)
|
||||
return item.id === nodeFinishedData.id
|
||||
@@ -839,7 +851,8 @@ export const useChat = (
|
||||
if (!responseItem.workflowProcess?.tracing)
|
||||
return
|
||||
const tracing = responseItem.workflowProcess.tracing
|
||||
const loopIndex = tracing.findIndex(item => item.id === loopFinishedData.id)!
|
||||
const loopIndex = tracing.findIndex(item => item.node_id === loopFinishedData.node_id
|
||||
&& (item.execution_metadata?.parallel_id === loopFinishedData.execution_metadata?.parallel_id || item.parallel_id === loopFinishedData.execution_metadata?.parallel_id))!
|
||||
if (loopIndex > -1) {
|
||||
tracing[loopIndex] = {
|
||||
...tracing[loopIndex],
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import type { NodeTracing } from '@/types/workflow'
|
||||
import { NodeRunningStatus } from '@/app/components/workflow/types'
|
||||
import { upsertTopLevelTracingNodeOnStart } from './top-level-tracing'
|
||||
|
||||
const createTrace = (overrides: Partial<NodeTracing> = {}): NodeTracing => ({
|
||||
id: 'trace-1',
|
||||
index: 0,
|
||||
predecessor_node_id: '',
|
||||
node_id: 'node-1',
|
||||
node_type: 'llm' as NodeTracing['node_type'],
|
||||
title: 'Node 1',
|
||||
inputs: {},
|
||||
inputs_truncated: false,
|
||||
process_data: {},
|
||||
process_data_truncated: false,
|
||||
outputs: {},
|
||||
outputs_truncated: false,
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
elapsed_time: 0,
|
||||
metadata: {
|
||||
iterator_length: 0,
|
||||
iterator_index: 0,
|
||||
loop_length: 0,
|
||||
loop_index: 0,
|
||||
},
|
||||
created_at: 0,
|
||||
created_by: {
|
||||
id: 'user-1',
|
||||
name: 'User',
|
||||
email: 'user@example.com',
|
||||
},
|
||||
finished_at: 0,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('upsertTopLevelTracingNodeOnStart', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should append a new top-level node when no matching trace exists', () => {
|
||||
const tracing: NodeTracing[] = []
|
||||
const startedNode = createTrace({
|
||||
id: 'trace-2',
|
||||
node_id: 'node-2',
|
||||
status: NodeRunningStatus.Running,
|
||||
})
|
||||
|
||||
const updated = upsertTopLevelTracingNodeOnStart(tracing, startedNode)
|
||||
|
||||
expect(updated).toBe(true)
|
||||
expect(tracing).toEqual([startedNode])
|
||||
})
|
||||
|
||||
it('should update an existing top-level node when the execution id matches', () => {
|
||||
const tracing: NodeTracing[] = [
|
||||
createTrace({
|
||||
id: 'trace-1',
|
||||
node_id: 'node-1',
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
}),
|
||||
]
|
||||
const startedNode = createTrace({
|
||||
id: 'trace-1',
|
||||
node_id: 'node-1',
|
||||
status: NodeRunningStatus.Running,
|
||||
})
|
||||
|
||||
const updated = upsertTopLevelTracingNodeOnStart(tracing, startedNode)
|
||||
|
||||
expect(updated).toBe(true)
|
||||
expect(tracing).toEqual([startedNode])
|
||||
})
|
||||
|
||||
it('should append a new top-level node when the same node starts with a new execution id', () => {
|
||||
const existingTrace = createTrace({
|
||||
id: 'trace-1',
|
||||
node_id: 'node-1',
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
})
|
||||
const tracing: NodeTracing[] = [existingTrace]
|
||||
const startedNode = createTrace({
|
||||
id: 'trace-2',
|
||||
node_id: 'node-1',
|
||||
status: NodeRunningStatus.Running,
|
||||
})
|
||||
|
||||
const updated = upsertTopLevelTracingNodeOnStart(tracing, startedNode)
|
||||
|
||||
expect(updated).toBe(true)
|
||||
expect(tracing).toEqual([existingTrace, startedNode])
|
||||
})
|
||||
|
||||
it('should ignore nested iteration node starts even when the node id matches a top-level trace', () => {
|
||||
const existingTrace = createTrace({
|
||||
id: 'top-level-trace',
|
||||
node_id: 'node-1',
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
})
|
||||
const tracing: NodeTracing[] = [existingTrace]
|
||||
const nestedIterationTrace = createTrace({
|
||||
id: 'iteration-trace',
|
||||
node_id: 'node-1',
|
||||
iteration_id: 'iteration-1',
|
||||
status: NodeRunningStatus.Running,
|
||||
})
|
||||
|
||||
const updated = upsertTopLevelTracingNodeOnStart(tracing, nestedIterationTrace)
|
||||
|
||||
expect(updated).toBe(false)
|
||||
expect(tracing).toEqual([existingTrace])
|
||||
})
|
||||
|
||||
it('should ignore nested loop node starts even when the node id matches a top-level trace', () => {
|
||||
const existingTrace = createTrace({
|
||||
id: 'top-level-trace',
|
||||
node_id: 'node-1',
|
||||
status: NodeRunningStatus.Succeeded,
|
||||
})
|
||||
const tracing: NodeTracing[] = [existingTrace]
|
||||
const nestedLoopTrace = createTrace({
|
||||
id: 'loop-trace',
|
||||
node_id: 'node-1',
|
||||
loop_id: 'loop-1',
|
||||
status: NodeRunningStatus.Running,
|
||||
})
|
||||
|
||||
const updated = upsertTopLevelTracingNodeOnStart(tracing, nestedLoopTrace)
|
||||
|
||||
expect(updated).toBe(false)
|
||||
expect(tracing).toEqual([existingTrace])
|
||||
})
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { NodeTracing } from '@/types/workflow'
|
||||
|
||||
const isNestedTracingNode = (trace: Pick<NodeTracing, 'iteration_id' | 'loop_id'>) => {
|
||||
return Boolean(trace.iteration_id || trace.loop_id)
|
||||
}
|
||||
|
||||
export const upsertTopLevelTracingNodeOnStart = (
|
||||
tracing: NodeTracing[],
|
||||
startedNode: NodeTracing,
|
||||
) => {
|
||||
if (isNestedTracingNode(startedNode))
|
||||
return false
|
||||
|
||||
const currentIndex = tracing.findIndex(item => item.id === startedNode.id)
|
||||
if (currentIndex > -1)
|
||||
// Started events are the authoritative snapshot for an execution; merging would retain stale client-side fields.
|
||||
tracing[currentIndex] = startedNode
|
||||
else
|
||||
tracing.push(startedNode)
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { getLocaleOnServer } from '@/i18n-config/server'
|
||||
import { ToastProvider } from './components/base/toast'
|
||||
import { ToastHost } from './components/base/ui/toast'
|
||||
import { TooltipProvider } from './components/base/ui/tooltip'
|
||||
import PartnerStackCookieRecorder from './components/billing/partner-stack/cookie-recorder'
|
||||
import { AgentationLoader } from './components/devtools/agentation-loader'
|
||||
import { ReactScanLoader } from './components/devtools/react-scan/loader'
|
||||
import { I18nServerProvider } from './components/provider/i18n-server'
|
||||
@@ -68,7 +67,6 @@ const LocaleLayout = async ({
|
||||
<TanstackQueryInitializer>
|
||||
<I18nServerProvider>
|
||||
<ToastHost timeout={5000} limit={3} />
|
||||
<PartnerStackCookieRecorder />
|
||||
<ToastProvider>
|
||||
<GlobalPublicStoreProvider>
|
||||
<TooltipProvider delay={300} closeDelay={200}>
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
'use client'
|
||||
import { useEffect } from 'react'
|
||||
import { useSearchParams } from '@/next/navigation'
|
||||
import usePSInfo from '../components/billing/partner-stack/use-ps-info'
|
||||
import NormalForm from './normal-form'
|
||||
import OneMoreStep from './one-more-step'
|
||||
|
||||
const SignIn = () => {
|
||||
const searchParams = useSearchParams()
|
||||
const step = searchParams.get('step')
|
||||
const { saveOrUpdate } = usePSInfo()
|
||||
|
||||
useEffect(() => {
|
||||
saveOrUpdate()
|
||||
}, [])
|
||||
|
||||
if (step === 'next')
|
||||
return <OneMoreStep />
|
||||
|
||||
@@ -965,6 +965,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/app/configuration/dataset-config/params-config/weighted-score.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/app/configuration/dataset-config/select-dataset/index.spec.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 2
|
||||
@@ -2038,6 +2043,11 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/base/features/new-feature-panel/annotation-reply/score-slider/base-slider/index.tsx": {
|
||||
"unicorn/prefer-number-properties": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/features/new-feature-panel/annotation-reply/type.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
@@ -2768,7 +2778,7 @@
|
||||
},
|
||||
"app/components/base/prompt-editor/plugins/workflow-variable-block/index.tsx": {
|
||||
"react-refresh/only-export-components": {
|
||||
"count": 4
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/base/prompt-editor/plugins/workflow-variable-block/node.tsx": {
|
||||
@@ -2868,6 +2878,19 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"app/components/base/slider/index.stories.tsx": {
|
||||
"no-console": {
|
||||
"count": 2
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/slider/index.tsx": {
|
||||
"tailwindcss/no-unnecessary-whitespace": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/base/sort/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 3
|
||||
@@ -7018,6 +7041,9 @@
|
||||
}
|
||||
},
|
||||
"app/components/workflow/nodes/_base/components/memory-config.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
},
|
||||
"unicorn/prefer-number-properties": {
|
||||
"count": 1
|
||||
}
|
||||
@@ -7846,6 +7872,12 @@
|
||||
"app/components/workflow/nodes/iteration/panel.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 2
|
||||
},
|
||||
"tailwindcss/no-unnecessary-whitespace": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/workflow/nodes/iteration/use-config.ts": {
|
||||
@@ -7874,6 +7906,9 @@
|
||||
"app/components/workflow/nodes/knowledge-base/components/index-method.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/workflow/nodes/knowledge-base/components/option-card.tsx": {
|
||||
|
||||
@@ -140,6 +140,7 @@
|
||||
"react-multi-email": "1.0.25",
|
||||
"react-papaparse": "4.4.0",
|
||||
"react-pdf-highlighter": "8.0.0-rc.0",
|
||||
"react-slider": "2.0.6",
|
||||
"react-sortablejs": "6.1.4",
|
||||
"react-syntax-highlighter": "15.6.6",
|
||||
"react-textarea-autosize": "8.5.9",
|
||||
@@ -201,6 +202,7 @@
|
||||
"@types/qs": "6.15.0",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/react-slider": "1.3.6",
|
||||
"@types/react-syntax-highlighter": "15.5.13",
|
||||
"@types/react-window": "1.8.8",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
|
||||
23
web/pnpm-lock.yaml
generated
23
web/pnpm-lock.yaml
generated
@@ -307,6 +307,9 @@ importers:
|
||||
react-pdf-highlighter:
|
||||
specifier: 8.0.0-rc.0
|
||||
version: 8.0.0-rc.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react-slider:
|
||||
specifier: 2.0.6
|
||||
version: 2.0.6(react@19.2.4)
|
||||
react-sortablejs:
|
||||
specifier: 6.1.4
|
||||
version: 6.1.4(@types/sortablejs@1.15.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sortablejs@1.15.7)
|
||||
@@ -485,6 +488,9 @@ importers:
|
||||
'@types/react-dom':
|
||||
specifier: 19.2.3
|
||||
version: 19.2.3(@types/react@19.2.14)
|
||||
'@types/react-slider':
|
||||
specifier: 1.3.6
|
||||
version: 1.3.6
|
||||
'@types/react-syntax-highlighter':
|
||||
specifier: 15.5.13
|
||||
version: 15.5.13
|
||||
@@ -3531,6 +3537,9 @@ packages:
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
|
||||
'@types/react-slider@1.3.6':
|
||||
resolution: {integrity: sha512-RS8XN5O159YQ6tu3tGZIQz1/9StMLTg/FCIPxwqh2gwVixJnlfIodtVx+fpXVMZHe7A58lAX1Q4XTgAGOQaCQg==}
|
||||
|
||||
'@types/react-syntax-highlighter@15.5.13':
|
||||
resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==}
|
||||
|
||||
@@ -6876,6 +6885,11 @@ packages:
|
||||
react-dom: ^19.2.4
|
||||
webpack: ^5.59.0
|
||||
|
||||
react-slider@2.0.6:
|
||||
resolution: {integrity: sha512-gJxG1HwmuMTJ+oWIRCmVWvgwotNCbByTwRkFZC6U4MBsHqJBmxwbYRJUmxy4Tke1ef8r9jfXjgkmY/uHOCEvbA==}
|
||||
peerDependencies:
|
||||
react: ^16 || ^17 || ^18
|
||||
|
||||
react-sortablejs@6.1.4:
|
||||
resolution: {integrity: sha512-fc7cBosfhnbh53Mbm6a45W+F735jwZ1UFIYSrIqcO/gRIFoDyZeMtgKlpV4DdyQfbCzdh5LoALLTDRxhMpTyXQ==}
|
||||
peerDependencies:
|
||||
@@ -10925,6 +10939,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@types/react-slider@1.3.6':
|
||||
dependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@types/react-syntax-highlighter@15.5.13':
|
||||
dependencies:
|
||||
'@types/react': 19.2.14
|
||||
@@ -14919,6 +14937,11 @@ snapshots:
|
||||
webpack: 5.105.4(esbuild@0.27.2)(uglify-js@3.19.3)
|
||||
webpack-sources: 3.3.4
|
||||
|
||||
react-slider@2.0.6(react@19.2.4):
|
||||
dependencies:
|
||||
prop-types: 15.8.1
|
||||
react: 19.2.4
|
||||
|
||||
react-sortablejs@6.1.4(@types/sortablejs@1.15.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sortablejs@1.15.7):
|
||||
dependencies:
|
||||
'@types/sortablejs': 1.15.9
|
||||
|
||||
Reference in New Issue
Block a user