mirror of
https://github.com/langgenius/dify.git
synced 2026-05-29 05:07:55 +08:00
Compare commits
1 Commits
codex/migr
...
fix/device
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a2f90e7ec |
@ -222,10 +222,6 @@ QUEUE_MONITOR_INTERVAL=30
|
||||
SWAGGER_UI_ENABLED=false
|
||||
SWAGGER_UI_PATH=/swagger-ui.html
|
||||
OPENAPI_ENABLED=false
|
||||
OPENAPI_CORS_ALLOW_ORIGINS=
|
||||
OPENAPI_KNOWN_CLIENT_IDS=difyctl
|
||||
OPENAPI_RATE_LIMIT_PER_TOKEN=60
|
||||
ENABLE_OAUTH_BEARER=false
|
||||
DSL_EXPORT_ENCRYPT_DATASET_ID=true
|
||||
DATASET_MAX_SEGMENTS_PER_REQUEST=0
|
||||
ENABLE_CLEAN_EMBEDDING_CACHE_TASK=false
|
||||
|
||||
@ -3693,6 +3693,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-icon.tsx": {
|
||||
"react/static-components": {
|
||||
"count": 2
|
||||
|
||||
@ -25,6 +25,7 @@ import {
|
||||
$setSelection,
|
||||
BLUR_COMMAND,
|
||||
FOCUS_COMMAND,
|
||||
KEY_ESCAPE_COMMAND,
|
||||
} from 'lexical'
|
||||
import * as React from 'react'
|
||||
import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
|
||||
@ -390,7 +391,7 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => {
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, '{{foo}}')
|
||||
})
|
||||
|
||||
it('handles workflow variable selection: flat vars (current/error_message/last_run)', async () => {
|
||||
it('handles workflow variable selection: flat vars (current/error_message/last_run) and closes on Escape from search input', async () => {
|
||||
const captures: Captures = { editor: null, eventEmitter: null }
|
||||
|
||||
const workflowVariableBlock = makeWorkflowVariableBlock({}, [
|
||||
@ -444,6 +445,16 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => {
|
||||
await flushNextTick()
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(INSERT_LAST_RUN_BLOCK_COMMAND, null)
|
||||
|
||||
// Re-open menu and press Escape in the VarReferenceVars search input to exercise handleClose().
|
||||
await setEditorText(editor, '{', true)
|
||||
await flushNextTick()
|
||||
const searchInput = await screen.findByPlaceholderText('workflow.common.searchVar')
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(searchInput, { key: 'Escape' })
|
||||
})
|
||||
await flushNextTick()
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(KEY_ESCAPE_COMMAND, expect.any(KeyboardEvent))
|
||||
|
||||
// Re-open menu and select a flat var that is not handled by the special-case list.
|
||||
// This covers the "no-op" path in the `isFlat` branch.
|
||||
dispatchSpy.mockClear()
|
||||
@ -589,7 +600,7 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('removes the full slash query when selecting a workflow variable', async () => {
|
||||
it('defaults to the first workflow variable and removes the full slash query when selecting by keyboard', async () => {
|
||||
const captures: Captures = { editor: null, eventEmitter: null }
|
||||
|
||||
const workflowVariableBlock = makeWorkflowVariableBlock({}, [
|
||||
@ -614,9 +625,18 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => {
|
||||
await setEditorText(editor, '/e', true)
|
||||
await flushNextTick()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(await screen.findByText('second_value'))
|
||||
})
|
||||
const firstItem = screen.getByText('first_value').closest('[data-selected]')
|
||||
const secondItem = screen.getByText('second_value').closest('[data-selected]')
|
||||
|
||||
expect(firstItem).toHaveAttribute('data-selected', 'true')
|
||||
expect(secondItem).toHaveAttribute('data-selected', 'false')
|
||||
|
||||
fireEvent.keyDown(document, { key: 'ArrowDown' })
|
||||
|
||||
expect(firstItem).toHaveAttribute('data-selected', 'false')
|
||||
expect(secondItem).toHaveAttribute('data-selected', 'true')
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Enter' })
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, ['node-1', 'second_value'])
|
||||
await waitFor(() => expect(readEditorText(editor)).not.toContain('/e'))
|
||||
|
||||
@ -9,13 +9,15 @@ vi.mock('../var-reference-vars', () => ({
|
||||
default: ({
|
||||
vars,
|
||||
onChange,
|
||||
itemWidth,
|
||||
isSupportFileVar,
|
||||
}: {
|
||||
vars: NodeOutPutVar[]
|
||||
onChange: (value: ValueSelector, item: Var) => void
|
||||
itemWidth?: number
|
||||
isSupportFileVar?: boolean
|
||||
}) => {
|
||||
mockVarReferenceVars({ vars, onChange, isSupportFileVar })
|
||||
mockVarReferenceVars({ vars, onChange, itemWidth, isSupportFileVar })
|
||||
return <div data-testid="var-reference-vars">{vars.length}</div>
|
||||
},
|
||||
}))
|
||||
@ -54,6 +56,7 @@ describe('AssignedVarReferencePopup', () => {
|
||||
render(
|
||||
<AssignedVarReferencePopup
|
||||
vars={[createOutputVar()]}
|
||||
itemWidth={280}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
@ -62,6 +65,7 @@ describe('AssignedVarReferencePopup', () => {
|
||||
expect(mockVarReferenceVars).toHaveBeenCalledWith({
|
||||
vars: [createOutputVar()],
|
||||
onChange,
|
||||
itemWidth: 280,
|
||||
isSupportFileVar: true,
|
||||
})
|
||||
})
|
||||
|
||||
@ -33,11 +33,13 @@ describe('VarReferenceVars', () => {
|
||||
vars: [{ variable: 'valid_name', type: VarType.string }],
|
||||
}])
|
||||
|
||||
it('should filter vars through the search box', () => {
|
||||
it('should filter vars through the search box and call onClose on escape', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<VarReferenceVars
|
||||
vars={baseVars}
|
||||
onChange={vi.fn()}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -45,6 +47,45 @@ describe('VarReferenceVars', () => {
|
||||
target: { value: 'valid' },
|
||||
})
|
||||
expect(screen.getByText('valid_name')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('workflow.common.searchVar'), { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should select the first visible variable by default and support arrow navigation in slash mode', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<VarReferenceVars
|
||||
hideSearch
|
||||
vars={createVars([{
|
||||
title: 'Node A',
|
||||
nodeId: 'node-a',
|
||||
vars: [
|
||||
{ variable: 'first_value', type: VarType.string },
|
||||
{ variable: 'second_value', type: VarType.string },
|
||||
],
|
||||
}])}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
const firstItem = screen.getByText('first_value').closest('[data-selected]')
|
||||
const secondItem = screen.getByText('second_value').closest('[data-selected]')
|
||||
|
||||
expect(firstItem).toHaveAttribute('data-selected', 'true')
|
||||
expect(secondItem).toHaveAttribute('data-selected', 'false')
|
||||
|
||||
fireEvent.keyDown(document, { key: 'ArrowDown' })
|
||||
|
||||
expect(firstItem).toHaveAttribute('data-selected', 'false')
|
||||
expect(secondItem).toHaveAttribute('data-selected', 'true')
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Enter' })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(['node-a', 'second_value'], expect.objectContaining({
|
||||
variable: 'second_value',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should call onChange when a variable item is chosen', () => {
|
||||
@ -76,7 +117,7 @@ describe('VarReferenceVars', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('status')).toHaveTextContent('workflow.common.noVar')
|
||||
expect(screen.getByText('workflow.common.noVar')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('manage-input'))
|
||||
expect(onManageInputField).toHaveBeenCalledTimes(1)
|
||||
@ -167,6 +208,43 @@ describe('VarReferenceVars', () => {
|
||||
expect(onChange).toHaveBeenNthCalledWith(4, ['node-special', 'asset'], expect.objectContaining({ variable: 'asset' }))
|
||||
})
|
||||
|
||||
it('should resolve selectors for special variables and file support from keyboard selection', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<VarReferenceVars
|
||||
hideSearch
|
||||
isSupportFileVar
|
||||
vars={createVars([
|
||||
{
|
||||
title: 'Specials',
|
||||
nodeId: 'node-special',
|
||||
vars: [
|
||||
{ variable: 'env.API_KEY', type: VarType.string },
|
||||
{ variable: 'conversation.user_name', type: VarType.string, des: 'User name' },
|
||||
{ variable: 'current', type: VarType.string },
|
||||
{ variable: 'asset', type: VarType.file },
|
||||
],
|
||||
},
|
||||
])}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Enter' })
|
||||
fireEvent.keyDown(document, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(document, { key: 'Enter' })
|
||||
fireEvent.keyDown(document, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(document, { key: 'Enter' })
|
||||
fireEvent.keyDown(document, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(document, { key: 'Enter' })
|
||||
|
||||
expect(onChange).toHaveBeenNthCalledWith(1, ['env', 'API_KEY'], expect.objectContaining({ variable: 'env.API_KEY' }))
|
||||
expect(onChange).toHaveBeenNthCalledWith(2, ['conversation', 'user_name'], expect.objectContaining({ variable: 'conversation.user_name' }))
|
||||
expect(onChange).toHaveBeenNthCalledWith(3, ['node-special', 'current'], expect.objectContaining({ variable: 'current' }))
|
||||
expect(onChange).toHaveBeenNthCalledWith(4, ['node-special', 'asset'], expect.objectContaining({ variable: 'asset' }))
|
||||
})
|
||||
|
||||
it('should render object vars and select them by node path', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
@ -246,4 +324,26 @@ describe('VarReferenceVars', () => {
|
||||
fireEvent.click(screen.getByText('asset'))
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should ignore file vars when file support is disabled during keyboard selection', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(
|
||||
<VarReferenceVars
|
||||
hideSearch
|
||||
vars={createVars([
|
||||
{
|
||||
title: 'Files',
|
||||
nodeId: 'node-files',
|
||||
vars: [{ variable: 'asset', type: VarType.file }],
|
||||
},
|
||||
])}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Enter' })
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -9,10 +9,12 @@ import VarReferenceVars from './var-reference-vars'
|
||||
type Props = {
|
||||
vars: NodeOutPutVar[]
|
||||
onChange: (value: ValueSelector, varDetail: Var) => void
|
||||
itemWidth?: number
|
||||
}
|
||||
const AssignedVarReferencePopup: FC<Props> = ({
|
||||
vars,
|
||||
onChange,
|
||||
itemWidth,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
// max-h-[300px] overflow-y-auto todo: use portal to handle long list
|
||||
@ -30,6 +32,7 @@ const AssignedVarReferencePopup: FC<Props> = ({
|
||||
searchBoxClassName="mt-1"
|
||||
vars={vars}
|
||||
onChange={onChange}
|
||||
itemWidth={itemWidth}
|
||||
isSupportFileVar
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -64,6 +64,7 @@ const VarReferencePopup: FC<Props> = ({
|
||||
searchBoxClassName="mt-1"
|
||||
vars={vars}
|
||||
onChange={onChange}
|
||||
itemWidth={itemWidth}
|
||||
isSupportFileVar={isSupportFileVar}
|
||||
showManageInputField={showManageRagInputFields}
|
||||
onManageInputField={() => setShowInputFieldPanel?.(true)}
|
||||
|
||||
@ -1,28 +1,20 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { StructuredOutput } from '../../../llm/types'
|
||||
import type { Field } from '@/app/components/workflow/nodes/llm/types'
|
||||
import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxClear,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxGroupLabel,
|
||||
ComboboxInput,
|
||||
ComboboxInputGroup,
|
||||
ComboboxItem,
|
||||
ComboboxItemText,
|
||||
ComboboxList,
|
||||
} from '@langgenius/dify-ui/combobox'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { useHover } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import PickerStructurePanel from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker'
|
||||
import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
@ -36,18 +28,9 @@ import {
|
||||
getVariableDisplayName,
|
||||
} from './var-reference-vars.helpers'
|
||||
|
||||
const VAR_SEARCH_INPUT_CLASS_NAME = 'var-search-input'
|
||||
export const VAR_REFERENCE_CHILD_POPUP_CLASS_NAME = 'var-reference-vars-child-popup'
|
||||
|
||||
type ReferenceVarItem = {
|
||||
nodeId: string
|
||||
title: string
|
||||
itemData: Var
|
||||
optionIndex: number
|
||||
isFlat?: boolean
|
||||
isException?: boolean
|
||||
isLoopVar?: boolean
|
||||
}
|
||||
|
||||
const resolveValueSelector = ({
|
||||
itemData,
|
||||
isFlat,
|
||||
@ -83,26 +66,41 @@ const resolveValueSelector = ({
|
||||
}
|
||||
|
||||
type ItemProps = {
|
||||
item: ReferenceVarItem
|
||||
nodeId: string
|
||||
title: string
|
||||
objPath: string[]
|
||||
itemData: Var
|
||||
onChange: (value: ValueSelector, item: Var) => void
|
||||
onHovering?: (value: boolean) => void
|
||||
itemWidth?: number
|
||||
isSupportFileVar?: boolean
|
||||
isException?: boolean
|
||||
isLoopVar?: boolean
|
||||
isFlat?: boolean
|
||||
isInCodeGeneratorInstructionEditor?: boolean
|
||||
className?: string
|
||||
preferSchemaType?: boolean
|
||||
isSelected?: boolean
|
||||
onActivate?: () => void
|
||||
}
|
||||
|
||||
function Item({
|
||||
item,
|
||||
const Item: FC<ItemProps> = ({
|
||||
nodeId,
|
||||
title,
|
||||
objPath,
|
||||
itemData,
|
||||
onChange,
|
||||
onHovering,
|
||||
isSupportFileVar,
|
||||
isException,
|
||||
isLoopVar,
|
||||
isFlat,
|
||||
isInCodeGeneratorInstructionEditor,
|
||||
className,
|
||||
preferSchemaType,
|
||||
}: ItemProps) {
|
||||
const {
|
||||
nodeId,
|
||||
title,
|
||||
itemData,
|
||||
isException,
|
||||
isFlat,
|
||||
isLoopVar,
|
||||
} = item
|
||||
isSelected,
|
||||
onActivate,
|
||||
}) => {
|
||||
const isStructureOutput = itemData.type === VarType.object && (itemData.children as StructuredOutput)?.schema?.properties
|
||||
const isObj = ([VarType.object, VarType.file].includes(itemData.type) && itemData.children && (itemData.children as Var[]).length > 0)
|
||||
const isEnv = itemData.variable.startsWith('env.')
|
||||
@ -161,16 +159,69 @@ function Item({
|
||||
return objStructuredOutput
|
||||
})()
|
||||
|
||||
const itemRef = useRef<HTMLDivElement>(null)
|
||||
const [isItemHovering, setIsItemHovering] = useState(false)
|
||||
useHover(itemRef, {
|
||||
onChange: (hovering) => {
|
||||
if (hovering) {
|
||||
setIsItemHovering(true)
|
||||
}
|
||||
else {
|
||||
if (isObj || isStructureOutput) {
|
||||
setTimeout(() => {
|
||||
setIsItemHovering(false)
|
||||
}, 100)
|
||||
}
|
||||
else {
|
||||
setIsItemHovering(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
const [isChildrenHovering, setIsChildrenHovering] = useState(false)
|
||||
const isHovering = isItemHovering || isChildrenHovering
|
||||
const open = (isObj || isStructureOutput) && isHovering
|
||||
useEffect(() => {
|
||||
onHovering?.(isHovering)
|
||||
}, [isHovering, onHovering])
|
||||
const handleChosen = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
e.nativeEvent.stopImmediatePropagation()
|
||||
const valueSelector = resolveValueSelector({
|
||||
itemData,
|
||||
isFlat,
|
||||
isSupportFileVar,
|
||||
nodeId,
|
||||
objPath,
|
||||
})
|
||||
|
||||
if (valueSelector)
|
||||
onChange(valueSelector, itemData)
|
||||
}
|
||||
const variableCategory = useMemo(
|
||||
() => getVariableCategory({ isEnv, isChatVar, isLoopVar, isRagVariable }),
|
||||
[isEnv, isChatVar, isLoopVar, isRagVariable],
|
||||
)
|
||||
|
||||
const itemTrigger = (
|
||||
<ComboboxItem
|
||||
value={item}
|
||||
<div
|
||||
ref={itemRef}
|
||||
className={cn(
|
||||
(isObj || isStructureOutput) ? 'pr-1' : 'pr-[18px]',
|
||||
(isHovering || isSelected) && ((isObj || isStructureOutput) ? 'bg-components-panel-on-panel-item-bg-hover' : 'bg-state-base-hover'),
|
||||
'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3 outline-hidden focus:outline-hidden focus-visible:outline-hidden',
|
||||
className,
|
||||
)}
|
||||
data-selected={isSelected ? 'true' : 'false'}
|
||||
onClick={handleChosen}
|
||||
onMouseEnter={onActivate}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.nativeEvent.stopImmediatePropagation()
|
||||
}}
|
||||
>
|
||||
<ComboboxItemText className="flex items-center gap-1 px-0">
|
||||
<div className="flex w-0 grow items-center">
|
||||
{!isFlat && (
|
||||
<VariableIconWithColor
|
||||
variables={itemData.variable.split('.')}
|
||||
@ -181,33 +232,33 @@ function Item({
|
||||
{isFlat && flatVarIcon}
|
||||
|
||||
{!isEnv && !isChatVar && !isRagVariable && (
|
||||
<span title={itemData.variable} className="min-w-0 grow truncate">{varName}</span>
|
||||
<div title={itemData.variable} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{varName}</div>
|
||||
)}
|
||||
{isEnv && (
|
||||
<span title={itemData.variable} className="min-w-0 grow truncate">{itemData.variable.replace('env.', '')}</span>
|
||||
<div title={itemData.variable} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{itemData.variable.replace('env.', '')}</div>
|
||||
)}
|
||||
{isChatVar && (
|
||||
<span title={itemData.des} className="min-w-0 grow truncate">{itemData.variable.replace('conversation.', '')}</span>
|
||||
<div title={itemData.des} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{itemData.variable.replace('conversation.', '')}</div>
|
||||
)}
|
||||
{isRagVariable && (
|
||||
<span title={itemData.des} className="min-w-0 grow truncate">{itemData.variable.split('.').slice(-1)[0]}</span>
|
||||
<div title={itemData.des} className="ml-1 w-0 grow truncate system-sm-medium text-text-secondary">{itemData.variable.split('.').slice(-1)[0]}</div>
|
||||
)}
|
||||
</ComboboxItemText>
|
||||
<span className="text-xs font-normal text-text-tertiary capitalize">{(preferSchemaType && itemData.schemaType) ? itemData.schemaType : itemData.type}</span>
|
||||
</div>
|
||||
<div className="ml-1 shrink-0 text-xs font-normal text-text-tertiary capitalize">{(preferSchemaType && itemData.schemaType) ? itemData.schemaType : itemData.type}</div>
|
||||
{
|
||||
(isObj || isStructureOutput) && (
|
||||
<span aria-hidden className="i-custom-vender-line-arrows-chevron-right size-3 text-text-quaternary" />
|
||||
<span aria-hidden className={cn('ml-0.5 i-custom-vender-line-arrows-chevron-right size-3 text-text-quaternary', isHovering && 'text-text-tertiary')} />
|
||||
)
|
||||
}
|
||||
</ComboboxItem>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!isObj && !isStructureOutput)
|
||||
return itemTrigger
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger nativeButton={false} openOnHover render={itemTrigger} />
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={noop}
|
||||
>
|
||||
<PopoverTrigger nativeButton={false} render={itemTrigger} />
|
||||
<PopoverContent
|
||||
placement="left-start"
|
||||
sideOffset={0}
|
||||
@ -217,6 +268,7 @@ function Item({
|
||||
<PickerStructurePanel
|
||||
root={{ nodeId, nodeName: title, attrName: itemData.variable, attrAlias: itemData.schemaType }}
|
||||
payload={structuredOutput!}
|
||||
onHovering={setIsChildrenHovering}
|
||||
onSelect={(valueSelector) => {
|
||||
onChange(valueSelector, itemData)
|
||||
}}
|
||||
@ -227,20 +279,6 @@ function Item({
|
||||
)
|
||||
}
|
||||
|
||||
function getReferenceVarLabel(item: ReferenceVarItem) {
|
||||
return getVariableDisplayName(item.itemData.variable, !!item.isFlat)
|
||||
}
|
||||
|
||||
function getReferenceVarValue(item: ReferenceVarItem) {
|
||||
return `${item.nodeId}:${item.itemData.variable}:${item.optionIndex}`
|
||||
}
|
||||
|
||||
function isSameReferenceVar(item: ReferenceVarItem, value: ReferenceVarItem) {
|
||||
return item.nodeId === value.nodeId
|
||||
&& item.itemData.variable === value.itemData.variable
|
||||
&& item.optionIndex === value.optionIndex
|
||||
}
|
||||
|
||||
type Props = {
|
||||
hideSearch?: boolean
|
||||
searchText?: string
|
||||
@ -248,6 +286,7 @@ type Props = {
|
||||
vars: NodeOutPutVar[]
|
||||
isSupportFileVar?: boolean
|
||||
onChange: (value: ValueSelector, item: Var) => void
|
||||
itemWidth?: number
|
||||
maxHeightClass?: string
|
||||
onClose?: () => void
|
||||
onBlur?: () => void
|
||||
@ -257,44 +296,68 @@ type Props = {
|
||||
autoFocus?: boolean
|
||||
preferSchemaType?: boolean
|
||||
}
|
||||
function VarReferenceVars({
|
||||
const VarReferenceVars: FC<Props> = ({
|
||||
hideSearch,
|
||||
searchText,
|
||||
searchBoxClassName,
|
||||
vars,
|
||||
isSupportFileVar,
|
||||
onChange,
|
||||
itemWidth,
|
||||
maxHeightClass,
|
||||
onClose,
|
||||
onBlur,
|
||||
isInCodeGeneratorInstructionEditor,
|
||||
showManageInputField,
|
||||
onManageInputField,
|
||||
autoFocus = true,
|
||||
preferSchemaType,
|
||||
}: Props) {
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [internalSearchValue, setInternalSearchValue] = useState('')
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const searchValue = searchText ?? internalSearchValue
|
||||
const filteredVars = useMemo(() => filterReferenceVars(vars, searchValue), [vars, searchValue])
|
||||
const groupedItems = useMemo(() => {
|
||||
const selectableItems = useMemo(() => {
|
||||
return filteredVars.flatMap(node => node.vars.map(item => ({
|
||||
nodeId: node.nodeId,
|
||||
isFlat: node.isFlat,
|
||||
itemData: item,
|
||||
})))
|
||||
}, [filteredVars])
|
||||
const indexedFilteredVars = useMemo(() => {
|
||||
let optionIndex = 0
|
||||
|
||||
return filteredVars.map(node => ({
|
||||
...node,
|
||||
vars: node.vars.map((variable): ReferenceVarItem => ({
|
||||
nodeId: node.nodeId,
|
||||
title: node.title,
|
||||
itemData: variable,
|
||||
isFlat: node.isFlat,
|
||||
isException: variable.isException,
|
||||
isLoopVar: node.isLoop,
|
||||
vars: node.vars.map(variable => ({
|
||||
variable,
|
||||
optionIndex: optionIndex++,
|
||||
})),
|
||||
}))
|
||||
}, [filteredVars])
|
||||
const selectableItems = useMemo(() => groupedItems.flatMap(node => node.vars), [groupedItems])
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
const effectiveSelectedIndex = selectableItems.length ? Math.min(Math.max(selectedIndex, 0), selectableItems.length - 1) : -1
|
||||
|
||||
const selectItem = useCallback((selectedItem?: ReferenceVarItem) => {
|
||||
useEffect(() => {
|
||||
const listElement = listRef.current
|
||||
const selectedElement = listElement?.querySelector('[data-selected="true"]') as HTMLElement | null
|
||||
if (!listElement || !selectedElement)
|
||||
return
|
||||
|
||||
const selectedTop = selectedElement.offsetTop
|
||||
const selectedBottom = selectedTop + selectedElement.offsetHeight
|
||||
const visibleTop = listElement.scrollTop
|
||||
const visibleBottom = visibleTop + listElement.clientHeight
|
||||
|
||||
if (selectedTop < visibleTop)
|
||||
listElement.scrollTop = selectedTop
|
||||
else if (selectedBottom > visibleBottom)
|
||||
listElement.scrollTop = selectedBottom - listElement.clientHeight
|
||||
}, [effectiveSelectedIndex])
|
||||
|
||||
const selectItem = useCallback((index: number) => {
|
||||
const selectedItem = selectableItems[index]
|
||||
if (!selectedItem)
|
||||
return
|
||||
|
||||
@ -309,97 +372,141 @@ function VarReferenceVars({
|
||||
|
||||
if (valueSelector)
|
||||
onChange(valueSelector, itemData)
|
||||
}, [isSupportFileVar, onChange])
|
||||
}, [isSupportFileVar, onChange, selectableItems])
|
||||
|
||||
const handleValueChange = useCallback((item: ReferenceVarItem | null) => {
|
||||
if (!item)
|
||||
const handleKeyboardEvent = useCallback((event: Pick<KeyboardEvent, 'key' | 'preventDefault' | 'stopPropagation'>) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
onClose?.()
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectableItems.length)
|
||||
return
|
||||
|
||||
selectItem(item)
|
||||
}, [selectItem])
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setSelectedIndex(
|
||||
event.key === 'ArrowDown'
|
||||
? Math.min(effectiveSelectedIndex + 1, selectableItems.length - 1)
|
||||
: Math.max(effectiveSelectedIndex - 1, 0),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const handleInputValueChange = useCallback((value: string) => {
|
||||
if (searchText === undefined)
|
||||
setInternalSearchValue(value)
|
||||
}, [searchText])
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
selectItem(effectiveSelectedIndex)
|
||||
}
|
||||
}, [effectiveSelectedIndex, onClose, selectableItems.length, selectItem])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
handleKeyboardEvent(e)
|
||||
}, [handleKeyboardEvent])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hideSearch)
|
||||
return
|
||||
|
||||
const handleDocumentKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.altKey || event.ctrlKey || event.metaKey)
|
||||
return
|
||||
if (!['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(event.key))
|
||||
return
|
||||
|
||||
handleKeyboardEvent(event)
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleDocumentKeyDown, true)
|
||||
return () => document.removeEventListener('keydown', handleDocumentKeyDown, true)
|
||||
}, [handleKeyboardEvent, hideSearch])
|
||||
|
||||
return (
|
||||
<Combobox<ReferenceVarItem>
|
||||
inline
|
||||
open
|
||||
value={null}
|
||||
items={selectableItems}
|
||||
inputValue={searchValue}
|
||||
onInputValueChange={handleInputValueChange}
|
||||
onValueChange={handleValueChange}
|
||||
filter={null}
|
||||
itemToStringLabel={getReferenceVarLabel}
|
||||
itemToStringValue={getReferenceVarValue}
|
||||
isItemEqualToValue={isSameReferenceVar}
|
||||
>
|
||||
<>
|
||||
{
|
||||
!hideSearch && (
|
||||
<div className={cn('m-2', searchBoxClassName)}>
|
||||
<ComboboxInputGroup>
|
||||
<ComboboxInput
|
||||
aria-label={t('common.searchVar', { ns: 'workflow' }) || ''}
|
||||
<>
|
||||
<div className={cn('m-2', searchBoxClassName)} onClick={e => e.stopPropagation()}>
|
||||
<Input
|
||||
className={VAR_SEARCH_INPUT_CLASS_NAME}
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
value={searchValue}
|
||||
placeholder={t('common.searchVar', { ns: 'workflow' }) || ''}
|
||||
onChange={e => setInternalSearchValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClear={() => setInternalSearchValue('')}
|
||||
onBlur={onBlur}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
{searchValue && (
|
||||
<ComboboxClear
|
||||
aria-label={t('operation.clear', { ns: 'common' })}
|
||||
/>
|
||||
)}
|
||||
</ComboboxInputGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="relative left-[-4px] h-[0.5px] bg-black/5"
|
||||
style={{
|
||||
width: 'calc(100% + 8px)',
|
||||
}}
|
||||
>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
{filteredVars.length > 0
|
||||
? (
|
||||
<ComboboxList className={maxHeightClass}>
|
||||
<div ref={listRef} className={cn('max-h-[85vh] overflow-x-hidden overflow-y-auto', maxHeightClass)}>
|
||||
{
|
||||
groupedItems.map((group, i) => (
|
||||
<ComboboxGroup key={group.nodeId} items={group.vars}>
|
||||
{!group.isFlat && (
|
||||
<ComboboxGroupLabel
|
||||
title={group.title}
|
||||
indexedFilteredVars.map((item, i) => (
|
||||
<div key={item.nodeId} className={cn(!item.isFlat && 'mt-3', i === 0 && item.isFlat && 'mt-2')}>
|
||||
{!item.isFlat && (
|
||||
<div
|
||||
className="truncate px-3 system-xs-medium-uppercase leading-[22px] text-text-tertiary"
|
||||
title={item.title}
|
||||
>
|
||||
{group.title}
|
||||
</ComboboxGroupLabel>
|
||||
{item.title}
|
||||
</div>
|
||||
)}
|
||||
{group.vars.map(item => (
|
||||
{item.vars.map(({ variable, optionIndex }) => (
|
||||
<Item
|
||||
key={item.optionIndex}
|
||||
item={item}
|
||||
key={optionIndex}
|
||||
title={item.title}
|
||||
nodeId={item.nodeId}
|
||||
objPath={[]}
|
||||
itemData={variable}
|
||||
onChange={onChange}
|
||||
itemWidth={itemWidth}
|
||||
isSupportFileVar={isSupportFileVar}
|
||||
isException={variable.isException}
|
||||
isLoopVar={item.isLoop}
|
||||
isFlat={item.isFlat}
|
||||
isInCodeGeneratorInstructionEditor={isInCodeGeneratorInstructionEditor}
|
||||
preferSchemaType={preferSchemaType}
|
||||
isSelected={effectiveSelectedIndex === optionIndex}
|
||||
onActivate={() => setSelectedIndex(optionIndex)}
|
||||
/>
|
||||
))}
|
||||
{group.isFlat && !groupedItems[i + 1]?.isFlat && !!groupedItems.find(item => !item.isFlat) && (
|
||||
{item.isFlat && !indexedFilteredVars[i + 1]?.isFlat && !!indexedFilteredVars.find(item => !item.isFlat) && (
|
||||
<div className="relative mt-[14px] flex items-center space-x-1">
|
||||
<div className="h-0 w-3 shrink-0 border border-divider-subtle"></div>
|
||||
<div className="system-2xs-semibold-uppercase text-text-tertiary">{t('debug.lastOutput', { ns: 'workflow' })}</div>
|
||||
<div className="h-0 shrink-0 grow border border-divider-subtle"></div>
|
||||
</div>
|
||||
)}
|
||||
</ComboboxGroup>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</ComboboxList>
|
||||
</div>
|
||||
)
|
||||
: <ComboboxEmpty>{t('common.noVar', { ns: 'workflow' })}</ComboboxEmpty>}
|
||||
: <div className="mt-2 pl-3 text-xs leading-[18px] font-medium text-gray-500 uppercase">{t('common.noVar', { ns: 'workflow' })}</div>}
|
||||
{
|
||||
showManageInputField && onManageInputField && (
|
||||
showManageInputField && (
|
||||
<ManageInputField
|
||||
onManage={onManageInputField}
|
||||
onManage={onManageInputField || noop}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Combobox>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -33,6 +33,7 @@ export default function DevicePage() {
|
||||
const pathname = usePathname()
|
||||
const urlUserCode = (searchParams.get('user_code') || '').trim().toUpperCase()
|
||||
const ssoVerified = searchParams.get('sso_verified') === '1'
|
||||
const ssoError = searchParams.get('sso_error') || ''
|
||||
|
||||
const [typed, setTyped] = useState('')
|
||||
const [view, setView] = useState<View>({ kind: 'code_entry' })
|
||||
@ -81,7 +82,11 @@ export default function DevicePage() {
|
||||
return
|
||||
}
|
||||
let consumed = false
|
||||
if (ssoVerified) {
|
||||
if (ssoError) {
|
||||
setErrMsg(ssoError) // eslint-disable-line react/set-state-in-effect
|
||||
consumed = true
|
||||
}
|
||||
else if (ssoVerified) {
|
||||
setView({ kind: 'authorize_sso' }) // eslint-disable-line react/set-state-in-effect
|
||||
consumed = true
|
||||
}
|
||||
@ -92,9 +97,9 @@ export default function DevicePage() {
|
||||
setView({ kind: 'chooser', userCode: urlUserCode }) // eslint-disable-line react/set-state-in-effect
|
||||
consumed = true
|
||||
}
|
||||
if (consumed && (urlUserCode || ssoVerified))
|
||||
if (consumed && (urlUserCode || ssoVerified || ssoError))
|
||||
router.replace(pathname)
|
||||
}, [urlUserCode, ssoVerified, account, view, router, pathname])
|
||||
}, [urlUserCode, ssoVerified, ssoError, account, view, router, pathname])
|
||||
|
||||
const onContinue = async () => {
|
||||
if (!isValidUserCode(typed))
|
||||
|
||||
Reference in New Issue
Block a user