dify/web/app/components/workflow/header/test-run-menu.tsx
Stephen Zhou eb71e47f3b
chore: migrate linting to vp lint with ESLint fallback (#38834)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-13 06:41:53 +00:00

205 lines
5.6 KiB
TypeScript

import type { ShortcutMapping } from './test-run-menu-helpers'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import {
forwardRef,
isValidElement,
useCallback,
useImperativeHandle,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { OptionRow, SingleOptionTrigger, useShortcutMenu } from './test-run-menu-helpers'
export enum TriggerType {
UserInput = 'user_input',
Schedule = 'schedule',
Webhook = 'webhook',
Plugin = 'plugin',
All = 'all',
}
export type TriggerOption = {
id: string
type: TriggerType
name: string
icon: React.ReactNode
nodeId?: string
relatedNodeIds?: string[]
enabled: boolean
}
export type TestRunOptions = {
userInput?: TriggerOption
triggers: TriggerOption[]
runAll?: TriggerOption
}
type TestRunMenuProps = {
options: TestRunOptions
onSelect: (option: TriggerOption) => void
children: React.ReactNode
}
export type TestRunMenuRef = {
toggle: () => void
}
const getEnabledOptions = (options: TestRunOptions) => {
const flattened: TriggerOption[] = []
if (options.userInput) flattened.push(options.userInput)
if (options.runAll) flattened.push(options.runAll)
flattened.push(...options.triggers)
return flattened.filter((option) => option.enabled !== false)
}
const getMenuVisibility = (options: TestRunOptions) => {
return {
hasUserInput: Boolean(options.userInput?.enabled !== false && options.userInput),
hasTriggers: options.triggers.some((trigger) => trigger.enabled !== false),
hasRunAll: Boolean(options.runAll?.enabled !== false && options.runAll),
}
}
const buildShortcutMappings = (options: TestRunOptions): ShortcutMapping[] => {
const mappings: ShortcutMapping[] = []
if (options.userInput && options.userInput.enabled !== false)
mappings.push({ option: options.userInput, shortcutKey: '~' })
let numericShortcut = 0
if (options.runAll && options.runAll.enabled !== false)
mappings.push({ option: options.runAll, shortcutKey: String(numericShortcut++) })
options.triggers.forEach((trigger) => {
if (trigger.enabled !== false)
mappings.push({ option: trigger, shortcutKey: String(numericShortcut++) })
})
return mappings
}
// oxlint-disable-next-line eslint-react/no-forward-ref
const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(
({ options, onSelect, children }, ref) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const shortcutMappings = useMemo(() => buildShortcutMappings(options), [options])
const shortcutKeyById = useMemo(() => {
const map = new Map<string, string>()
shortcutMappings.forEach(({ option, shortcutKey }) => {
map.set(option.id, shortcutKey)
})
return map
}, [shortcutMappings])
const handleSelect = useCallback(
(option: TriggerOption) => {
onSelect(option)
setOpen(false)
},
[onSelect],
)
const enabledOptions = useMemo(() => getEnabledOptions(options), [options])
const hasSingleEnabledOption = enabledOptions.length === 1
const soleEnabledOption = hasSingleEnabledOption ? enabledOptions[0] : undefined
const runSoleOption = useCallback(() => {
if (soleEnabledOption) handleSelect(soleEnabledOption)
}, [handleSelect, soleEnabledOption])
useShortcutMenu({
open,
shortcutMappings,
handleSelect,
})
useImperativeHandle(
ref,
() => ({
toggle: () => {
if (hasSingleEnabledOption) {
runSoleOption()
return
}
setOpen((prev) => !prev)
},
}),
[hasSingleEnabledOption, runSoleOption],
)
const renderOption = (option: TriggerOption) => {
return (
<OptionRow
key={option.id}
option={option}
shortcutKey={shortcutKeyById.get(option.id)}
onSelect={handleSelect}
/>
)
}
const { hasUserInput, hasTriggers, hasRunAll } = useMemo(
() => getMenuVisibility(options),
[options],
)
if (hasSingleEnabledOption && soleEnabledOption) {
return <SingleOptionTrigger runSoleOption={runSoleOption}>{children}</SingleOptionTrigger>
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
{isValidElement(children) ? (
<DropdownMenuTrigger render={children} style={{ userSelect: 'none' }} />
) : (
<DropdownMenuTrigger style={{ userSelect: 'none' }}>{children}</DropdownMenuTrigger>
)}
<DropdownMenuContent
placement="bottom-start"
sideOffset={8}
alignOffset={-4}
popupClassName="w-[284px] p-1"
>
<DropdownMenuGroup>
<DropdownMenuLabel className="mb-1 px-3 pt-2 text-sm font-medium text-text-primary">
{t(($) => $['common.chooseStartNodeToRun'], { ns: 'workflow' })}
</DropdownMenuLabel>
<div>
{hasUserInput && renderOption(options.userInput!)}
{(hasTriggers || hasRunAll) && hasUserInput && (
<DropdownMenuSeparator className="mx-3" />
)}
{hasRunAll && renderOption(options.runAll!)}
{hasTriggers &&
options.triggers
.filter((trigger) => trigger.enabled !== false)
.map((trigger) => renderOption(trigger))}
</div>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
},
)
TestRunMenu.displayName = 'TestRunMenu'
export default TestRunMenu