mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
fix: make sortable lists keyboard accessible (#41974)
This commit is contained in:
51 files changed
+1401
-290
No files matched your search
@@ -1086,7 +1086,7 @@
|
||||
},
|
||||
"web/app/components/base/icons/src/vender/line/others/index.ts": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 5
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"web/app/components/base/icons/src/vender/line/time/index.ts": {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { IConfigVarProps } from '../index'
|
||||
import type { ExternalDataTool } from '@/models/common'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { vi } from 'vite-plus/test'
|
||||
import { toast } from '@/app/components/app/configuration/toast'
|
||||
@@ -152,6 +153,29 @@ describe('ConfigVar', () => {
|
||||
expect(onPromptVariablesChange).toHaveBeenCalledWith([secondVar, firstVar])
|
||||
})
|
||||
|
||||
it('commits variable keyboard sorting only after confirmation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onPromptVariablesChange = vi.fn()
|
||||
const first = createPromptVariable({ key: 'first', name: 'First' })
|
||||
const second = createPromptVariable({ key: 'second', name: 'Second' })
|
||||
renderConfigVar({ promptVariables: [first, second], onPromptVariablesChange })
|
||||
const handle = screen.getAllByRole('button', { name: /sort.handle/ })[0]!
|
||||
handle.focus()
|
||||
await user.keyboard('{Enter}{ArrowDown}')
|
||||
expect(handle).toHaveFocus()
|
||||
expect(onPromptVariablesChange).not.toHaveBeenCalled()
|
||||
await user.keyboard('{Enter}')
|
||||
expect(onPromptVariablesChange).toHaveBeenCalledExactlyOnceWith([second, first])
|
||||
})
|
||||
|
||||
it('does not expose keyboard sorting in readonly mode', () => {
|
||||
renderConfigVar({
|
||||
readonly: true,
|
||||
promptVariables: [createPromptVariable(), createPromptVariable()],
|
||||
})
|
||||
expect(screen.queryByRole('button', { name: /sort.handle/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should ignore sortable updates when the variable order is unchanged', () => {
|
||||
const onPromptVariablesChange = vi.fn()
|
||||
const firstVar = createPromptVariable({ key: 'first', name: 'First' })
|
||||
|
||||
+52
@@ -1,4 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import ConfigSelect from '../index'
|
||||
|
||||
vi.mock('react-sortablejs', () => ({
|
||||
@@ -82,4 +84,54 @@ describe('ConfigSelect Component', () => {
|
||||
|
||||
expect(defaultProps.onChange).toHaveBeenCalledWith(['Option 2', 'Option 1'])
|
||||
})
|
||||
it('previews keyboard sorting and commits only when confirmed, keeping input arrows separate', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const Fixture = () => {
|
||||
const [options, setOptions] = useState(['First', 'Second', 'Third'])
|
||||
return (
|
||||
<ConfigSelect
|
||||
options={options}
|
||||
onChange={(next) => {
|
||||
onChange(next)
|
||||
setOptions(next)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
render(<Fixture />)
|
||||
const handle = screen.getAllByRole('button', { name: /sort.handle/ })[0]!
|
||||
handle.focus()
|
||||
await user.keyboard('{Enter}{ArrowDown}')
|
||||
expect(
|
||||
screen.getAllByRole('textbox').map((input) => (input as HTMLInputElement).value),
|
||||
).toEqual(['Second', 'First', 'Third'])
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
expect(handle).toHaveFocus()
|
||||
await user.keyboard('{Enter}')
|
||||
expect(onChange).toHaveBeenCalledExactlyOnceWith(['Second', 'First', 'Third'])
|
||||
await user.tab()
|
||||
expect(screen.getByDisplayValue('First')).toHaveFocus()
|
||||
await user.keyboard('{ArrowDown}')
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('cancels a keyboard reorder with duplicate options and preserves the original order', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<ConfigSelect options={['Same', 'Same', 'Last']} onChange={onChange} />)
|
||||
const handle = screen.getAllByRole('button', { name: /sort.handle/ })[1]!
|
||||
handle.focus()
|
||||
await user.keyboard(' {ArrowDown}')
|
||||
expect(
|
||||
screen.getAllByRole('textbox').map((input) => (input as HTMLInputElement).value),
|
||||
).toEqual(['Same', 'Last', 'Same'])
|
||||
expect(handle).toHaveFocus()
|
||||
await user.keyboard('{Escape}')
|
||||
expect(
|
||||
screen.getAllByRole('textbox').map((input) => (input as HTMLInputElement).value),
|
||||
).toEqual(['Same', 'Same', 'Last'])
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
expect(handle).toHaveFocus()
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { RiAddLine, RiDeleteBinLine, RiDraggable } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReactSortable } from 'react-sortablejs'
|
||||
import { useKeyboardSortable } from '@/app/components/base/keyboard-sortable/use-keyboard-sortable'
|
||||
|
||||
export type Options = string[]
|
||||
type IConfigSelectProps = {
|
||||
@@ -18,26 +20,31 @@ const ConfigSelect: FC<IConfigSelectProps> = ({ options, onChange }) => {
|
||||
const [focusID, setFocusID] = useState<number | null>(null)
|
||||
const [deletingID, setDeletingID] = useState<number | null>(null)
|
||||
|
||||
const optionList = options.map((content, index) => {
|
||||
return {
|
||||
id: index,
|
||||
name: content,
|
||||
}
|
||||
const { items, getHandleProps, getItemKey, isSorting, announcement } = useKeyboardSortable({
|
||||
items: options,
|
||||
onChange,
|
||||
getItemLabel: (item) => item,
|
||||
})
|
||||
const optionList = useMemo(() => items.map((name, index) => ({ id: index, name })), [items])
|
||||
|
||||
return (
|
||||
<div>
|
||||
{announcement}
|
||||
{options.length > 0 && (
|
||||
<div className="mb-1">
|
||||
<ReactSortable
|
||||
className="space-y-1"
|
||||
list={optionList}
|
||||
setList={(list) => onChange(list.map((item) => item.name))}
|
||||
setList={(list) => {
|
||||
if (!isSorting && list.some((item, index) => item.name !== options[index]))
|
||||
onChange(list.map((item) => item.name))
|
||||
}}
|
||||
disabled={isSorting}
|
||||
handle=".handle"
|
||||
ghostClass="opacity-50"
|
||||
animation={150}
|
||||
>
|
||||
{options.map((o, index) => (
|
||||
{items.map((o, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex items-center rounded-lg border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg pl-2.5 hover:bg-components-panel-on-panel-item-bg-hover',
|
||||
@@ -46,18 +53,23 @@ const ConfigSelect: FC<IConfigSelectProps> = ({ options, onChange }) => {
|
||||
deletingID === index &&
|
||||
'border-components-input-border-destructive bg-state-destructive-hover hover:border-components-input-border-destructive hover:bg-state-destructive-hover',
|
||||
)}
|
||||
key={index}
|
||||
key={getItemKey(index)}
|
||||
>
|
||||
<RiDraggable className="handle size-4 cursor-grab text-text-quaternary" />
|
||||
<IconButton
|
||||
{...getHandleProps(index)}
|
||||
className="handle size-6 shrink-0 cursor-grab aria-pressed:bg-state-accent-hover"
|
||||
>
|
||||
<RiDraggable aria-hidden="true" className="size-4 text-text-quaternary" />
|
||||
</IconButton>
|
||||
<input
|
||||
key={index}
|
||||
key={getItemKey(index)}
|
||||
type="input"
|
||||
value={o || ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
onChange(
|
||||
options.map((item, i) => {
|
||||
if (index === i) return value
|
||||
if (getItemKey(index) === i) return value
|
||||
|
||||
return item
|
||||
}),
|
||||
@@ -72,7 +84,7 @@ const ConfigSelect: FC<IConfigSelectProps> = ({ options, onChange }) => {
|
||||
aria-label={t(($) => $['operation.delete'], { ns: 'common' })}
|
||||
className="absolute top-1/2 right-1.5 block translate-y-[-50%] cursor-pointer rounded-md border-none bg-transparent p-1 text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive focus-visible:ring-1 focus-visible:ring-state-destructive-border focus-visible:outline-hidden"
|
||||
onClick={() => {
|
||||
onChange(options.filter((_, i) => index !== i))
|
||||
onChange(options.filter((_, i) => getItemKey(index) !== i))
|
||||
setDeletingID(null)
|
||||
}}
|
||||
onMouseEnter={() => setDeletingID(index)}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { ReactSortable } from 'react-sortablejs'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { toast } from '@/app/components/app/configuration/toast'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import { useKeyboardSortable } from '@/app/components/base/keyboard-sortable/use-keyboard-sortable'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
@@ -270,26 +271,39 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
|
||||
[handleOpenExternalDataToolModal, promptVariables, showEditModal],
|
||||
)
|
||||
|
||||
const {
|
||||
items: variables,
|
||||
getHandleProps,
|
||||
getItemKey,
|
||||
isSorting,
|
||||
announcement,
|
||||
} = useKeyboardSortable({
|
||||
items: promptVariables,
|
||||
onChange: (items) => onPromptVariablesChange?.(items),
|
||||
disabled: readonly,
|
||||
getItemLabel: (item) => item.name || item.key,
|
||||
})
|
||||
|
||||
const promptVariablesWithIds = useMemo(
|
||||
() =>
|
||||
promptVariables.map((item) => {
|
||||
variables.map((item) => {
|
||||
return {
|
||||
id: item.key,
|
||||
variable: { ...item },
|
||||
}
|
||||
}),
|
||||
[promptVariables],
|
||||
[variables],
|
||||
)
|
||||
const handlePromptVariablesReorder = useCallback(
|
||||
(list: typeof promptVariablesWithIds) => {
|
||||
const hasOrderChanged =
|
||||
list.length !== promptVariables.length ||
|
||||
list.some((item, index) => item.id !== promptVariables[index]?.key)
|
||||
if (!hasOrderChanged) return
|
||||
if (isSorting || !hasOrderChanged) return
|
||||
|
||||
onPromptVariablesChange?.(list.map((item) => item.variable))
|
||||
},
|
||||
[onPromptVariablesChange, promptVariables],
|
||||
[isSorting, onPromptVariablesChange, promptVariables],
|
||||
)
|
||||
|
||||
const canDrag = !readonly && promptVariables.length > 1
|
||||
@@ -313,6 +327,7 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
|
||||
headerRight={!readonly ? <SelectVarType onChange={handleAddVar} /> : null}
|
||||
noBodySpacing
|
||||
>
|
||||
{announcement}
|
||||
{!hasVar && (
|
||||
<div className="mt-1 px-3 pb-3">
|
||||
<div className="pt-2 pb-1 text-xs text-text-tertiary">
|
||||
@@ -326,6 +341,7 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
|
||||
className={cn('grid-col-1 grid space-y-1', readonly && 'grid-cols-2 gap-1 space-y-0')}
|
||||
list={promptVariablesWithIds}
|
||||
setList={handlePromptVariablesReorder}
|
||||
disabled={readonly || isSorting}
|
||||
handle=".handle"
|
||||
ghostClass="opacity-50"
|
||||
animation={150}
|
||||
@@ -334,17 +350,25 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
|
||||
const { key, name, type, required, config, icon, icon_background } = item.variable
|
||||
return (
|
||||
<VarItem
|
||||
className={cn(canDrag && 'handle')}
|
||||
key={key}
|
||||
dragHandleProps={getHandleProps(index)}
|
||||
readonly={readonly}
|
||||
name={key}
|
||||
label={name}
|
||||
required={!!required}
|
||||
type={type}
|
||||
onEdit={() =>
|
||||
handleConfig({ type, key, index, name, config, icon, icon_background })
|
||||
handleConfig({
|
||||
type,
|
||||
key,
|
||||
index: getItemKey(index),
|
||||
name,
|
||||
config,
|
||||
icon,
|
||||
icon_background,
|
||||
})
|
||||
}
|
||||
onRemove={() => handleRemoveVar(index)}
|
||||
onRemove={() => handleRemoveVar(getItemKey(index))}
|
||||
canDrag={canDrag}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { IInputTypeIconProps } from './input-type-icon'
|
||||
import type { useKeyboardSortable } from '@/app/components/base/keyboard-sortable/use-keyboard-sortable'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { RiDeleteBinLine, RiDraggable, RiEditLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
@@ -20,6 +22,7 @@ type ItemProps = {
|
||||
onEdit: () => void
|
||||
onRemove: () => void
|
||||
canDrag?: boolean
|
||||
dragHandleProps?: ReturnType<ReturnType<typeof useKeyboardSortable>['getHandleProps']>
|
||||
}
|
||||
|
||||
const VarItem: FC<ItemProps> = ({
|
||||
@@ -32,6 +35,7 @@ const VarItem: FC<ItemProps> = ({
|
||||
onEdit,
|
||||
onRemove,
|
||||
canDrag,
|
||||
dragHandleProps,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
@@ -46,10 +50,19 @@ const VarItem: FC<ItemProps> = ({
|
||||
)}
|
||||
>
|
||||
<VarIcon
|
||||
className={cn('mr-1 size-4 shrink-0 text-text-accent', canDrag && 'group-hover:opacity-0')}
|
||||
className={cn(
|
||||
'mr-1 size-4 shrink-0 text-text-accent',
|
||||
canDrag && 'group-focus-within:opacity-0 group-hover:opacity-0',
|
||||
)}
|
||||
/>
|
||||
{canDrag && (
|
||||
<RiDraggable className="absolute top-3 left-3 hidden size-3 cursor-pointer text-text-tertiary group-hover:block" />
|
||||
<IconButton
|
||||
{...dragHandleProps}
|
||||
aria-label={dragHandleProps?.['aria-label'] ?? name}
|
||||
className="handle pointer-events-none absolute left-1.5 size-6 cursor-grab opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100 aria-pressed:bg-state-accent-hover"
|
||||
>
|
||||
<RiDraggable aria-hidden="true" className="size-4 text-text-tertiary" />
|
||||
</IconButton>
|
||||
)}
|
||||
<div className="flex w-0 grow items-center">
|
||||
<div className="truncate" title={`${name} · ${label}`}>
|
||||
|
||||
+21
@@ -561,4 +561,25 @@ describe('OpeningSettingModal', () => {
|
||||
await userEvent.click(screen.getByTestId('cancel-add'))
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
it('cancels sorting with Escape without closing the dialog, then saves a confirmed reorder', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSave = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
render(<OpeningSettingModal data={defaultData} onSave={onSave} onCancel={onCancel} />)
|
||||
const handle = screen.getAllByRole('button', { name: /sort.handle/ })[0]!
|
||||
await user.click(handle)
|
||||
await user.keyboard('{Enter}{ArrowDown}{Escape}')
|
||||
expect(onCancel).not.toHaveBeenCalled()
|
||||
expect(
|
||||
screen
|
||||
.getAllByPlaceholderText('appDebug.openingStatement.openingQuestionPlaceholder')
|
||||
.map((input) => (input as HTMLInputElement).value),
|
||||
).toEqual(['Question 1', 'Question 2'])
|
||||
expect(handle).toHaveFocus()
|
||||
await user.keyboard('{Enter}{ArrowDown}{Enter}')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||
expect(onSave).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ suggested_questions: ['Question 2', 'Question 1'] }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import type { PromptVariable } from '@/models/debug'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
@@ -14,6 +15,7 @@ import ConfirmAddVar from '@/app/components/app/configuration/config-prompt/conf
|
||||
import { getInputKeys } from '@/app/components/base/block-input'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import { useKeyboardSortable } from '@/app/components/base/keyboard-sortable/use-keyboard-sortable'
|
||||
import PromptEditor from '@/app/components/base/prompt-editor'
|
||||
import { checkKeys, getNewVar } from '@/utils/var'
|
||||
|
||||
@@ -121,6 +123,22 @@ const OpeningSettingModal = ({
|
||||
</span>
|
||||
)
|
||||
|
||||
const {
|
||||
items: questions,
|
||||
getHandleProps,
|
||||
getItemKey,
|
||||
isSorting,
|
||||
announcement,
|
||||
} = useKeyboardSortable({
|
||||
items: tempSuggestedQuestions,
|
||||
onChange: setTempSuggestedQuestions,
|
||||
getItemLabel: (item) => item,
|
||||
})
|
||||
const questionList = useMemo(
|
||||
() => questions.map((name, index) => ({ id: index, name })),
|
||||
[questions],
|
||||
)
|
||||
|
||||
const renderQuestions = () => {
|
||||
return (
|
||||
<div>
|
||||
@@ -144,20 +162,23 @@ const OpeningSettingModal = ({
|
||||
</div>
|
||||
</div>
|
||||
<Divider bgStyle="gradient" className="mb-3 h-px" />
|
||||
{announcement}
|
||||
<ReactSortable
|
||||
className="space-y-1"
|
||||
list={tempSuggestedQuestions.map((name, index) => {
|
||||
return {
|
||||
id: index,
|
||||
name,
|
||||
}
|
||||
})}
|
||||
setList={(list) => setTempSuggestedQuestions(list.map((item) => item.name))}
|
||||
list={questionList}
|
||||
setList={(list) => {
|
||||
if (
|
||||
!isSorting &&
|
||||
list.some((item, index) => item.name !== tempSuggestedQuestions[index])
|
||||
)
|
||||
setTempSuggestedQuestions(list.map((item) => item.name))
|
||||
}}
|
||||
disabled={isSorting}
|
||||
handle=".handle"
|
||||
ghostClass="opacity-50"
|
||||
animation={150}
|
||||
>
|
||||
{tempSuggestedQuestions.map((question, index) => {
|
||||
{questions.map((question, index) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -167,9 +188,14 @@ const OpeningSettingModal = ({
|
||||
focusID === index &&
|
||||
'border-components-input-border-active bg-components-input-bg-active hover:border-components-input-border-active hover:bg-components-input-bg-active',
|
||||
)}
|
||||
key={index}
|
||||
key={getItemKey(index)}
|
||||
>
|
||||
<span className="handle i-ri-draggable size-4 cursor-grab text-text-quaternary" />
|
||||
<IconButton
|
||||
{...getHandleProps(index)}
|
||||
className="handle size-6 shrink-0 cursor-grab aria-pressed:bg-state-accent-hover"
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-draggable size-4 text-text-quaternary" />
|
||||
</IconButton>
|
||||
<input
|
||||
type="input"
|
||||
value={question || ''}
|
||||
@@ -182,7 +208,7 @@ const OpeningSettingModal = ({
|
||||
const value = e.target.value
|
||||
setTempSuggestedQuestions(
|
||||
tempSuggestedQuestions.map((item, i) => {
|
||||
if (index === i) return value
|
||||
if (getItemKey(index) === i) return value
|
||||
|
||||
return item
|
||||
}),
|
||||
@@ -200,7 +226,9 @@ const OpeningSettingModal = ({
|
||||
<div
|
||||
className="absolute top-1/2 right-1.5 block translate-y-[-50%] cursor-pointer rounded-md p-1 text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive"
|
||||
onClick={() => {
|
||||
setTempSuggestedQuestions(tempSuggestedQuestions.filter((_, i) => index !== i))
|
||||
setTempSuggestedQuestions(
|
||||
tempSuggestedQuestions.filter((_, i) => getItemKey(index) !== i),
|
||||
)
|
||||
}}
|
||||
onMouseEnter={() => setDeletingID(index)}
|
||||
onMouseLeave={() => setDeletingID(null)}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "16",
|
||||
"height": "16",
|
||||
"viewBox": "0 0 16 16",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"id": "Drag Handle"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"id": "drag-handle",
|
||||
"fill-rule": "evenodd",
|
||||
"clip-rule": "evenodd",
|
||||
"d": "M6 5C6.55228 5 7 4.55228 7 4C7 3.44772 6.55228 3 6 3C5.44772 3 5 3.44772 5 4C5 4.55228 5.44772 5 6 5ZM6 9C6.55228 9 7 8.55228 7 8C7 7.44772 6.55228 7 6 7C5.44772 7 5 7.44772 5 8C5 8.55228 5.44772 9 6 9ZM11 4C11 4.55228 10.5523 5 10 5C9.44772 5 9 4.55228 9 4C9 3.44772 9.44772 3 10 3C10.5523 3 11 3.44772 11 4ZM10 9C10.5523 9 11 8.55228 11 8C11 7.44772 10.5523 7 10 7C9.44772 7 9 7.44772 9 8C9 8.55228 9.44772 9 10 9ZM7 12C7 12.5523 6.55228 13 6 13C5.44772 13 5 12.5523 5 12C5 11.4477 5.44772 11 6 11C6.55228 11 7 11.4477 7 12ZM10 13C10.5523 13 11 12.5523 11 12C11 11.4477 10.5523 11 10 11C9.44772 11 9 11.4477 9 12C9 12.5523 9.44772 13 10 13Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "DragHandle"
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './DragHandle.json'
|
||||
|
||||
const Icon = ({
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
}) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'DragHandle'
|
||||
|
||||
export default Icon
|
||||
@@ -1,6 +1,5 @@
|
||||
export { default as BubbleX } from './BubbleX'
|
||||
|
||||
export { default as DragHandle } from './DragHandle'
|
||||
export { default as Env } from './Env'
|
||||
export { default as GlobalVariable } from './GlobalVariable'
|
||||
export { default as Icon3Dots } from './Icon3Dots'
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { useState } from 'react'
|
||||
import { page, userEvent } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import ConfigSelect from '@/app/components/app/configuration/config-var/config-select'
|
||||
|
||||
const save = vi.fn()
|
||||
|
||||
function Options() {
|
||||
const [options, setOptions] = useState(['Alpha', 'Beta', 'Gamma'])
|
||||
return (
|
||||
<div style={{ width: 400, padding: 32 }}>
|
||||
<button type="button">Before options</button>
|
||||
<ConfigSelect
|
||||
options={options}
|
||||
onChange={(next) => {
|
||||
save(next)
|
||||
setOptions(next)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
it('keeps native focus on reordered options and preserves pointer sorting', async () => {
|
||||
// Browser-owned: native Tab, focus after React moves DOM nodes, and SortableJS pointer hit testing.
|
||||
save.mockClear()
|
||||
await page.viewport(800, 600)
|
||||
const screen = await render(<Options />)
|
||||
const before = screen.getByRole('button', { name: 'Before options' })
|
||||
const alpha = screen.getByRole('button', { name: /common\.sort\.handle.*Alpha/ })
|
||||
const beta = screen.getByRole('button', { name: /common\.sort\.handle.*Beta/ })
|
||||
const gamma = screen.getByRole('button', { name: /common\.sort\.handle.*Gamma/ })
|
||||
const values = () =>
|
||||
screen
|
||||
.getByRole('textbox')
|
||||
.elements()
|
||||
.map((input) => (input as HTMLInputElement).value)
|
||||
await before.click()
|
||||
await userEvent.tab()
|
||||
await expect.element(alpha).toHaveFocus()
|
||||
const focusStyle = getComputedStyle(alpha.element())
|
||||
expect(focusStyle.boxShadow !== 'none' || focusStyle.outlineStyle !== 'none').toBe(true)
|
||||
await userEvent.keyboard(' {ArrowDown}')
|
||||
expect(values()).toEqual(['Beta', 'Alpha', 'Gamma'])
|
||||
await expect.element(alpha).toHaveFocus()
|
||||
expect(alpha.element().getBoundingClientRect().top).toBeGreaterThan(
|
||||
beta.element().getBoundingClientRect().top,
|
||||
)
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
await userEvent.keyboard('{Escape}')
|
||||
expect(values()).toEqual(['Alpha', 'Beta', 'Gamma'])
|
||||
await expect.element(alpha).toHaveFocus()
|
||||
await userEvent.keyboard('{Enter}{ArrowDown}{Enter}')
|
||||
await expect.element(alpha).toHaveFocus()
|
||||
expect(save).toHaveBeenCalledExactlyOnceWith(['Beta', 'Alpha', 'Gamma'])
|
||||
await userEvent.tab()
|
||||
await userEvent.keyboard('{ArrowUp}')
|
||||
expect(save).toHaveBeenCalledTimes(1)
|
||||
await userEvent.dragAndDrop(alpha, gamma, { targetPosition: { x: 12, y: 23 } })
|
||||
await expect.poll(values).toEqual(['Beta', 'Gamma', 'Alpha'])
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { useKeyboardSortable } from '../use-keyboard-sortable'
|
||||
|
||||
function List({
|
||||
onChange,
|
||||
minIndex = 0,
|
||||
itemsOverride,
|
||||
disabledOverride,
|
||||
}: {
|
||||
onChange: (items: string[]) => void
|
||||
minIndex?: number
|
||||
itemsOverride?: string[]
|
||||
disabledOverride?: boolean
|
||||
}) {
|
||||
const [items, setItems] = useState(['Pinned', 'Second', 'Third'])
|
||||
const [disabled, setDisabled] = useState(false)
|
||||
const sortable = useKeyboardSortable({
|
||||
items: itemsOverride ?? items,
|
||||
minIndex,
|
||||
disabled: disabledOverride ?? disabled,
|
||||
getItemLabel: (item) => item,
|
||||
onChange: (next) => {
|
||||
setItems(next)
|
||||
onChange(next)
|
||||
},
|
||||
})
|
||||
return (
|
||||
<>
|
||||
{sortable.announcement}
|
||||
<ol>
|
||||
{sortable.items.map((item, index) => (
|
||||
<li key={sortable.getItemKey(index)}>
|
||||
<span>{item}</span>
|
||||
<IconButton {...sortable.getHandleProps(index)}>
|
||||
<span aria-hidden="true">↕</span>
|
||||
</IconButton>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<button type="button" onClick={() => setItems(['External', 'Update'])}>
|
||||
Replace items
|
||||
</button>
|
||||
<button type="button" onClick={() => setDisabled((value) => !value)}>
|
||||
Toggle permission
|
||||
</button>
|
||||
<input aria-label="Editor" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const order = () =>
|
||||
screen.getAllByRole('listitem').map((item) => item.textContent?.replace('↕', ''))
|
||||
const handle = (index: number) =>
|
||||
screen.getAllByRole('button', { name: /^common\.sort\.handle/ })[index]!
|
||||
|
||||
it('honors a pinned prefix and keeps focus on the moved item after commit and cancel', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<List onChange={onChange} minIndex={1} />)
|
||||
expect(handle(0)).toBeDisabled()
|
||||
await user.tab()
|
||||
await user.keyboard('{Enter}{ArrowUp}{ArrowDown}{ArrowDown}')
|
||||
expect(order()).toEqual(['Pinned', 'Third', 'Second'])
|
||||
expect(handle(2)).toHaveFocus()
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
await user.keyboard('{Enter}')
|
||||
expect(onChange).toHaveBeenCalledExactlyOnceWith(['Pinned', 'Third', 'Second'])
|
||||
expect(handle(2)).toHaveFocus()
|
||||
await user.keyboard(' {ArrowUp}{Escape}')
|
||||
expect(order()).toEqual(['Pinned', 'Third', 'Second'])
|
||||
expect(handle(2)).toHaveFocus()
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('cancels on leaving the handle and never commits stale items after external updates', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<List onChange={onChange} />)
|
||||
await user.tab()
|
||||
await user.keyboard(' {ArrowDown}')
|
||||
await user.click(screen.getByRole('button', { name: 'Replace items' }))
|
||||
expect(order()).toEqual(['External', 'Update'])
|
||||
expect(screen.getByRole('button', { name: 'Replace items' })).toHaveFocus()
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
handle(0).focus()
|
||||
await user.keyboard(' {ArrowDown}')
|
||||
await user.click(screen.getByRole('textbox', { name: 'Editor' }))
|
||||
await user.keyboard('{ArrowUp}edited')
|
||||
expect(order()).toEqual(['External', 'Update'])
|
||||
expect(screen.getByRole('textbox', { name: 'Editor' })).toHaveValue('edited')
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not reactivate cancelled sorting when permissions are restored', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(<List onChange={onChange} />)
|
||||
await user.tab()
|
||||
await user.keyboard('{Enter}{ArrowDown}')
|
||||
rerender(<List onChange={onChange} disabledOverride />)
|
||||
expect(handle(0)).toBeDisabled()
|
||||
rerender(<List onChange={onChange} disabledOverride={false} />)
|
||||
expect(order()).toEqual(['Pinned', 'Second', 'Third'])
|
||||
expect(handle(0)).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('discards a preview when new items arrive without moving focus away first', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(<List onChange={onChange} />)
|
||||
await user.tab()
|
||||
await user.keyboard('{Enter}{ArrowDown}')
|
||||
rerender(<List onChange={onChange} itemsOverride={['External', 'Update']} />)
|
||||
expect(order()).toEqual(['External', 'Update'])
|
||||
expect(handle(0)).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(handle(1)).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -0,0 +1,185 @@
|
||||
'use client'
|
||||
|
||||
import { useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type Options<T> = {
|
||||
items: T[]
|
||||
onChange: (items: T[]) => void
|
||||
disabled?: boolean
|
||||
minIndex?: number
|
||||
getItemLabel?: (item: T, index: number) => string
|
||||
}
|
||||
|
||||
type SortSession<T> = {
|
||||
source: T[]
|
||||
order: number[]
|
||||
origin: number
|
||||
minIndex: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export function useKeyboardSortable<T>({
|
||||
items,
|
||||
onChange,
|
||||
disabled = false,
|
||||
minIndex = 0,
|
||||
getItemLabel,
|
||||
}: Options<T>) {
|
||||
const { t } = useTranslation('common')
|
||||
const descriptionId = useId()
|
||||
const [session, setSession] = useState<SortSession<T> | null>(null)
|
||||
const [message, setMessage] = useState('')
|
||||
const handlesRef = useRef(new Map<number, HTMLButtonElement>())
|
||||
const pendingFocusRef = useRef<number | null>(null)
|
||||
const current =
|
||||
session?.source === items && !disabled && session.minIndex === minIndex ? session : null
|
||||
const displayedItems = current ? current.order.map((index) => items[index]!) : items
|
||||
const activeIndex = current?.order.indexOf(current.origin) ?? -1
|
||||
|
||||
// External edits or permission changes end the local preview without overwriting them.
|
||||
if (session && !current) {
|
||||
setSession(null)
|
||||
setMessage(
|
||||
t(($) => $['sort.cancelled'], {
|
||||
item: session.label,
|
||||
position: session.origin + 1,
|
||||
count: items.length,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (pendingFocusRef.current === null) return
|
||||
const handle = handlesRef.current.get(pendingFocusRef.current)
|
||||
handle?.focus()
|
||||
handle?.scrollIntoView?.({ block: 'nearest' })
|
||||
pendingFocusRef.current = null
|
||||
})
|
||||
|
||||
const cancel = (restoreFocus: boolean) => {
|
||||
if (!current) return
|
||||
if (restoreFocus) pendingFocusRef.current = current.origin
|
||||
setSession(null)
|
||||
setMessage(
|
||||
t(($) => $['sort.cancelled'], {
|
||||
item: current.label,
|
||||
position: current.origin + 1,
|
||||
count: items.length,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const toggle = (index: number) => {
|
||||
if (disabled || index < minIndex || items.length - minIndex < 2) return
|
||||
if (current) {
|
||||
pendingFocusRef.current = activeIndex
|
||||
setSession(null)
|
||||
setMessage(
|
||||
t(($) => $['sort.finished'], {
|
||||
item: current.label,
|
||||
position: activeIndex + 1,
|
||||
count: items.length,
|
||||
}),
|
||||
)
|
||||
if (current.order.some((original, position) => original !== position))
|
||||
onChange(displayedItems)
|
||||
return
|
||||
}
|
||||
const label =
|
||||
getItemLabel?.(items[index]!, index) || t(($) => $['sort.item'], { position: index + 1 })
|
||||
setSession({
|
||||
source: items,
|
||||
order: items.map((_, position) => position),
|
||||
origin: index,
|
||||
minIndex,
|
||||
label,
|
||||
})
|
||||
setMessage(
|
||||
t(($) => $['sort.started'], { item: label, position: index + 1, count: items.length }),
|
||||
)
|
||||
}
|
||||
|
||||
const getHandleProps = (index: number) => ({
|
||||
type: 'button' as const,
|
||||
ref: (element: HTMLButtonElement | null) => {
|
||||
if (element) handlesRef.current.set(index, element)
|
||||
else handlesRef.current.delete(index)
|
||||
},
|
||||
'aria-label': t(($) => $['sort.handle'], {
|
||||
item:
|
||||
getItemLabel?.(displayedItems[index]!, index) ||
|
||||
t(($) => $['sort.item'], { position: index + 1 }),
|
||||
}),
|
||||
'aria-describedby': descriptionId,
|
||||
'aria-pressed': activeIndex === index,
|
||||
disabled: disabled || index < minIndex || items.length - minIndex < 2,
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
// Virtual clicks support assistive technology without starting a sort after pointer dragging.
|
||||
if (event.detail === 0) toggle(index)
|
||||
},
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.altKey || event.ctrlKey || event.metaKey || event.nativeEvent.isComposing) return
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!event.repeat) toggle(index)
|
||||
return
|
||||
}
|
||||
if (!current || activeIndex !== index) return
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
cancel(true)
|
||||
} else if (event.key === 'Tab') {
|
||||
cancel(false)
|
||||
} else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const nextIndex = Math.max(
|
||||
minIndex,
|
||||
Math.min(items.length - 1, index + (event.key === 'ArrowUp' ? -1 : 1)),
|
||||
)
|
||||
if (nextIndex === index) return
|
||||
const order = [...current.order]
|
||||
const [moved] = order.splice(index, 1)
|
||||
order.splice(nextIndex, 0, moved!)
|
||||
pendingFocusRef.current = nextIndex
|
||||
setSession({ ...current, order })
|
||||
setMessage(
|
||||
t(($) => $['sort.moved'], {
|
||||
item: current.label,
|
||||
position: nextIndex + 1,
|
||||
count: items.length,
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
onKeyUp: (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
},
|
||||
onBlur: () => {
|
||||
if (pendingFocusRef.current === null) cancel(false)
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
items: displayedItems,
|
||||
getHandleProps,
|
||||
getItemKey: (index: number) => current?.order[index] ?? index,
|
||||
isSorting: !!current,
|
||||
announcement: (
|
||||
<>
|
||||
<span id={descriptionId} className="sr-only">
|
||||
{t(($) => $['sort.instructions'])}
|
||||
</span>
|
||||
<span role="status" aria-live="polite" aria-atomic="true" className="sr-only">
|
||||
{message}
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
}
|
||||
}
|
||||
+28
-7
@@ -1,5 +1,6 @@
|
||||
import type { InputVar } from '@/models/pipeline'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import FieldListContainer from '../field-list-container'
|
||||
|
||||
@@ -26,7 +27,7 @@ describe('FieldListContainer', () => {
|
||||
|
||||
it('should render the field items inside the sortable container', () => {
|
||||
const onListSortChange = vi.fn()
|
||||
const { container } = render(
|
||||
render(
|
||||
<FieldListContainer
|
||||
inputFields={[createInputVar('field_1'), createInputVar('field_2')]}
|
||||
onListSortChange={onListSortChange}
|
||||
@@ -37,12 +38,12 @@ describe('FieldListContainer', () => {
|
||||
|
||||
expect(screen.getAllByText('field_1').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('field_2').length).toBeGreaterThan(0)
|
||||
expect(container.querySelector('.handle')).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('button', { name: /sort.handle/ })).toHaveLength(2)
|
||||
expect(onListSortChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should honor readonly mode for the rendered field rows', () => {
|
||||
const { container } = render(
|
||||
render(
|
||||
<FieldListContainer
|
||||
readonly
|
||||
inputFields={[createInputVar('field_1'), createInputVar('field_2')]}
|
||||
@@ -52,9 +53,29 @@ describe('FieldListContainer', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const firstRow = container.querySelector('.handle')
|
||||
fireEvent.mouseEnter(firstRow!)
|
||||
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(0)
|
||||
})
|
||||
it('commits the preview order with the sortable payload only after keyboard confirmation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onListSortChange = vi.fn()
|
||||
const inputFields = [createInputVar('first'), createInputVar('second')]
|
||||
render(
|
||||
<FieldListContainer
|
||||
inputFields={inputFields}
|
||||
onListSortChange={onListSortChange}
|
||||
onRemoveField={vi.fn()}
|
||||
onEditField={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const handle = screen.getAllByRole('button', { name: /sort.handle/ })[0]!
|
||||
handle.focus()
|
||||
await user.keyboard('{Enter}{ArrowDown}')
|
||||
expect(handle).toHaveFocus()
|
||||
expect(onListSortChange).not.toHaveBeenCalled()
|
||||
await user.keyboard('{Enter}')
|
||||
expect(onListSortChange).toHaveBeenCalledExactlyOnceWith([
|
||||
expect.objectContaining({ variable: 'second', id: 'second' }),
|
||||
expect.objectContaining({ variable: 'first', id: 'first' }),
|
||||
])
|
||||
})
|
||||
})
|
||||
+27
-7
@@ -1,4 +1,5 @@
|
||||
'use client'
|
||||
import type { useKeyboardSortable } from '@/app/components/base/keyboard-sortable/use-keyboard-sortable'
|
||||
import type { InputVarType } from '@/app/components/workflow/types'
|
||||
import type { InputVar } from '@/models/pipeline'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
@@ -14,13 +15,21 @@ import InputVarTypeIcon from '@/app/components/workflow/nodes/_base/components/i
|
||||
|
||||
type FieldItemProps = {
|
||||
readonly?: boolean
|
||||
dragHandleProps?: ReturnType<ReturnType<typeof useKeyboardSortable>['getHandleProps']>
|
||||
payload: InputVar
|
||||
index: number
|
||||
onClickEdit: (id: string) => void
|
||||
onRemove: (index: number) => void
|
||||
}
|
||||
|
||||
const FieldItem = ({ readonly, payload, index, onClickEdit, onRemove }: FieldItemProps) => {
|
||||
const FieldItem = ({
|
||||
readonly,
|
||||
payload,
|
||||
index,
|
||||
onClickEdit,
|
||||
onRemove,
|
||||
dragHandleProps,
|
||||
}: FieldItemProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const ref = useRef(null)
|
||||
@@ -48,18 +57,29 @@ const FieldItem = ({ readonly, payload, index, onClickEdit, onRemove }: FieldIte
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'handle flex h-8 cursor-pointer items-center justify-between gap-x-1 rounded-lg border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg py-1 pl-2 shadow-xs hover:shadow-sm',
|
||||
'group flex h-8 cursor-pointer items-center justify-between gap-x-1 rounded-lg border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg py-1 pl-2 shadow-xs hover:shadow-sm',
|
||||
isHovering && !readonly ? 'cursor-all-scroll pr-1' : 'pr-2.5',
|
||||
readonly && 'cursor-default',
|
||||
)}
|
||||
// onClick={handleOnClickEdit}
|
||||
>
|
||||
<div className="flex grow basis-0 items-center gap-x-1 overflow-hidden">
|
||||
{isHovering && !readonly ? (
|
||||
<RiDraggable className="size-4 shrink-0 text-text-quaternary" />
|
||||
) : (
|
||||
<InputField className="size-4 shrink-0 text-text-accent" />
|
||||
<div className="relative size-4 shrink-0">
|
||||
<InputField
|
||||
className={cn(
|
||||
'size-4 text-text-accent',
|
||||
!readonly && !!dragHandleProps && 'group-focus-within:opacity-0 group-hover:opacity-0',
|
||||
)}
|
||||
/>
|
||||
{!readonly && dragHandleProps && (
|
||||
<IconButton
|
||||
{...dragHandleProps}
|
||||
className="handle pointer-events-none absolute -top-1 -left-1 size-6 cursor-grab opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100 aria-pressed:bg-state-accent-hover"
|
||||
>
|
||||
<RiDraggable aria-hidden="true" className="size-4 text-text-quaternary" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex grow basis-0 items-center gap-x-1 overflow-hidden">
|
||||
<div
|
||||
title={payload.variable}
|
||||
className="max-w-32.5 shrink-0 truncate system-sm-medium text-text-secondary"
|
||||
|
||||
+43
-25
@@ -1,9 +1,9 @@
|
||||
import type { SortableItem } from './types'
|
||||
import type { InputVar } from '@/models/pipeline'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { isEqual } from 'es-toolkit/predicate'
|
||||
import { memo, useCallback, useMemo } from 'react'
|
||||
import { ReactSortable } from 'react-sortablejs'
|
||||
import { useKeyboardSortable } from '@/app/components/base/keyboard-sortable/use-keyboard-sortable'
|
||||
import FieldItem from './field-item'
|
||||
|
||||
type FieldListContainerProps = {
|
||||
@@ -22,8 +22,17 @@ const FieldListContainer = ({
|
||||
onEditField,
|
||||
readonly,
|
||||
}: FieldListContainerProps) => {
|
||||
const { items, getHandleProps, getItemKey, isSorting, announcement } = useKeyboardSortable({
|
||||
items: inputFields,
|
||||
onChange: (fields) =>
|
||||
onListSortChange(
|
||||
fields.map((field) => ({ ...field, id: field.variable, chosen: false, selected: false })),
|
||||
),
|
||||
disabled: readonly,
|
||||
getItemLabel: (item) => item.label || item.variable,
|
||||
})
|
||||
const list = useMemo(() => {
|
||||
return inputFields.map((content) => {
|
||||
return items.map((content) => {
|
||||
return {
|
||||
id: content.variable,
|
||||
chosen: false,
|
||||
@@ -31,37 +40,46 @@ const FieldListContainer = ({
|
||||
...content,
|
||||
}
|
||||
})
|
||||
}, [inputFields])
|
||||
}, [items])
|
||||
|
||||
const handleListSortChange = useCallback(
|
||||
(newList: SortableItem[]) => {
|
||||
if (isEqual(newList, list)) return
|
||||
if (
|
||||
isSorting ||
|
||||
(newList.length === inputFields.length &&
|
||||
newList.every((item, index) => item.variable === inputFields[index]?.variable))
|
||||
)
|
||||
return
|
||||
onListSortChange(newList)
|
||||
},
|
||||
[list, onListSortChange],
|
||||
[isSorting, inputFields, onListSortChange],
|
||||
)
|
||||
|
||||
return (
|
||||
<ReactSortable<SortableItem>
|
||||
className={cn(className)}
|
||||
list={list}
|
||||
setList={handleListSortChange}
|
||||
handle=".handle"
|
||||
ghostClass="opacity-50"
|
||||
animation={150}
|
||||
disabled={readonly}
|
||||
>
|
||||
{inputFields?.map((item, index) => (
|
||||
<FieldItem
|
||||
key={index}
|
||||
index={index}
|
||||
readonly={readonly}
|
||||
payload={item}
|
||||
onRemove={onRemoveField}
|
||||
onClickEdit={onEditField}
|
||||
/>
|
||||
))}
|
||||
</ReactSortable>
|
||||
<>
|
||||
{announcement}
|
||||
<ReactSortable<SortableItem>
|
||||
className={cn(className)}
|
||||
list={list}
|
||||
setList={handleListSortChange}
|
||||
handle=".handle"
|
||||
ghostClass="opacity-50"
|
||||
animation={150}
|
||||
disabled={readonly || isSorting}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<FieldItem
|
||||
key={getItemKey(index)}
|
||||
dragHandleProps={getHandleProps(index)}
|
||||
index={getItemKey(index)}
|
||||
readonly={readonly}
|
||||
payload={item}
|
||||
onRemove={onRemoveField}
|
||||
onClickEdit={onEditField}
|
||||
/>
|
||||
))}
|
||||
</ReactSortable>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+121
-5
@@ -1,12 +1,10 @@
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import type { Variable } from '@/app/components/workflow/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import VarList from '../var-list'
|
||||
|
||||
vi.mock('react-sortablejs', () => ({
|
||||
ReactSortable: ({ children }: PropsWithChildren) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../var-reference-picker', () => ({
|
||||
default: () => <button type="button">Variable value</button>,
|
||||
}))
|
||||
@@ -38,3 +36,121 @@ describe('VarList', () => {
|
||||
expect(onChange).toHaveBeenCalledWith([expect.objectContaining({ variable: 'renamed_input' })])
|
||||
})
|
||||
})
|
||||
|
||||
const initialVariables: Variable[] = ['alpha', 'beta', 'gamma'].map((variable) => ({
|
||||
variable,
|
||||
value_selector: ['node-id', variable],
|
||||
value_type: VarType.string,
|
||||
}))
|
||||
|
||||
function SortableVariables({
|
||||
onChange,
|
||||
readonly = false,
|
||||
}: {
|
||||
onChange: (items: Variable[]) => void
|
||||
readonly?: boolean
|
||||
}) {
|
||||
const [variables, setVariables] = useState(initialVariables)
|
||||
return (
|
||||
<VarList
|
||||
nodeId="node-id"
|
||||
readonly={readonly}
|
||||
list={variables}
|
||||
onChange={(items) => {
|
||||
onChange(items)
|
||||
setVariables(items)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
it('tabs through each variable from the reorder handle to its name, value and remove button', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SortableVariables onChange={vi.fn()} />)
|
||||
const handles = screen.getAllByRole('button', { pressed: false })
|
||||
const names = screen.getAllByRole('textbox')
|
||||
const values = screen.getAllByRole('button', { name: 'Variable value' })
|
||||
const removeButtons = screen.getAllByRole('button', { name: 'common.operation.remove' })
|
||||
|
||||
for (let index = 0; index < initialVariables.length; index++) {
|
||||
for (const control of [handles[index], names[index], values[index], removeButtons[index]]) {
|
||||
await user.tab()
|
||||
expect(control).toHaveFocus()
|
||||
}
|
||||
}
|
||||
|
||||
await user.tab({ shift: true })
|
||||
expect(values[2]).toHaveFocus()
|
||||
await user.tab({ shift: true })
|
||||
expect(names[2]).toHaveFocus()
|
||||
await user.tab({ shift: true })
|
||||
expect(handles[2]).toHaveFocus()
|
||||
})
|
||||
|
||||
it('previews variable ordering, commits only on confirmation, and keeps the moved handle focused', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<SortableVariables onChange={onChange} />)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
const handle = screen.getAllByRole('button', { pressed: false })[0]!
|
||||
await user.tab()
|
||||
expect(handle).toHaveFocus()
|
||||
await user.keyboard('{Enter}{ArrowDown}')
|
||||
expect(screen.getAllByRole('textbox').map((input) => (input as HTMLInputElement).value)).toEqual([
|
||||
'beta',
|
||||
'alpha',
|
||||
'gamma',
|
||||
])
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { pressed: true })).toHaveFocus()
|
||||
await user.keyboard('{Enter}')
|
||||
expect(onChange).toHaveBeenCalledExactlyOnceWith([
|
||||
initialVariables[1],
|
||||
initialVariables[0],
|
||||
initialVariables[2],
|
||||
])
|
||||
expect(screen.getAllByRole('button', { pressed: false })[1]).toHaveFocus()
|
||||
})
|
||||
|
||||
it('cancels a variable reorder and leaves arrow keys inside name inputs available for editing', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<SortableVariables onChange={onChange} />)
|
||||
await user.tab()
|
||||
await user.tab()
|
||||
expect(screen.getAllByRole('textbox')[0]).toHaveFocus()
|
||||
await user.keyboard('{ArrowDown}')
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
const handle = screen.getAllByRole('button', { pressed: false })[0]!
|
||||
await user.tab({ shift: true })
|
||||
expect(handle).toHaveFocus()
|
||||
await user.keyboard(' {ArrowDown}{Escape}')
|
||||
expect(screen.getAllByRole('textbox').map((input) => (input as HTMLInputElement).value)).toEqual([
|
||||
'alpha',
|
||||
'beta',
|
||||
'gamma',
|
||||
])
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
expect(handle).toHaveFocus()
|
||||
})
|
||||
|
||||
it('does not expose variable sorting in readonly mode', () => {
|
||||
render(<SortableVariables onChange={vi.fn()} readonly />)
|
||||
expect(screen.queryByRole('button', { pressed: false })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('cancels a preview before deleting the variable whose remove button was activated', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<SortableVariables onChange={onChange} />)
|
||||
const handle = screen.getAllByRole('button', { pressed: false })[0]!
|
||||
await user.tab()
|
||||
expect(handle).toHaveFocus()
|
||||
await user.keyboard('{Enter}{ArrowDown}')
|
||||
await user.click(screen.getAllByRole('button', { name: 'common.operation.remove' })[1]!)
|
||||
expect(onChange).toHaveBeenCalledExactlyOnceWith([initialVariables[1], initialVariables[2]])
|
||||
expect(screen.getAllByRole('textbox').map((input) => (input as HTMLInputElement).value)).toEqual([
|
||||
'beta',
|
||||
'gamma',
|
||||
])
|
||||
})
|
||||
@@ -2,16 +2,16 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ValueSelector, Var, Variable } from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiDraggable } from '@remixicon/react'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReactSortable } from 'react-sortablejs'
|
||||
import { v4 as uuid4 } from 'uuid'
|
||||
import { useKeyboardSortable } from '@/app/components/base/keyboard-sortable/use-keyboard-sortable'
|
||||
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
|
||||
import { checkKeys, replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var'
|
||||
import RemoveButton from '../remove-button'
|
||||
@@ -43,16 +43,20 @@ const VarList: FC<Props> = ({
|
||||
const { t } = useTranslation()
|
||||
const variableNameLabel = t(($) => $['common.variableNamePlaceholder'], { ns: 'workflow' })
|
||||
|
||||
const keyboardSort = useKeyboardSortable({
|
||||
items: list,
|
||||
onChange,
|
||||
disabled: readonly,
|
||||
getItemLabel: (item) => item.variable,
|
||||
})
|
||||
|
||||
const listWithIds = useMemo(
|
||||
() =>
|
||||
list.map((item) => {
|
||||
const id = uuid4()
|
||||
return {
|
||||
id,
|
||||
variable: { ...item },
|
||||
}
|
||||
}),
|
||||
[list],
|
||||
keyboardSort.items.map((item, index) => ({
|
||||
id: index,
|
||||
variable: { ...item },
|
||||
})),
|
||||
[keyboardSort.items],
|
||||
)
|
||||
|
||||
const { run: validateVarInput } = useDebounceFn(
|
||||
@@ -142,61 +146,75 @@ const VarList: FC<Props> = ({
|
||||
const varCount = list.length
|
||||
|
||||
return (
|
||||
<ReactSortable
|
||||
className="space-y-2"
|
||||
list={listWithIds}
|
||||
setList={(list) => {
|
||||
onChange(list.map((item) => item.variable))
|
||||
}}
|
||||
handle=".handle"
|
||||
ghostClass="opacity-50"
|
||||
animation={150}
|
||||
>
|
||||
{list.map((variable, index) => {
|
||||
const canDrag = (() => {
|
||||
if (readonly) return false
|
||||
return varCount > 1
|
||||
})()
|
||||
return (
|
||||
<div className={cn('flex items-center space-x-1', 'group relative')} key={index}>
|
||||
<Input
|
||||
aria-label={variableNameLabel}
|
||||
className="w-30"
|
||||
disabled={readonly}
|
||||
value={variable.variable}
|
||||
onChange={handleVarNameChange(index)}
|
||||
placeholder={variableNameLabel}
|
||||
/>
|
||||
<VarReferencePicker
|
||||
nodeId={nodeId}
|
||||
readonly={readonly}
|
||||
isShowNodeName
|
||||
className="grow"
|
||||
value={
|
||||
variable.variable_type === VarKindType.constant
|
||||
? variable.value || ''
|
||||
: variable.value_selector || []
|
||||
}
|
||||
isSupportConstantValue={isSupportConstantValue}
|
||||
onChange={handleVarReferenceChange(index)}
|
||||
defaultVarKindType={variable.variable_type}
|
||||
onlyLeafNodeVar={onlyLeafNodeVar}
|
||||
filterVar={filterVar}
|
||||
isSupportFileVar={isSupportFileVar}
|
||||
/>
|
||||
{!readonly && <RemoveButton onClick={handleVarRemove(index)} />}
|
||||
{canDrag && (
|
||||
<RiDraggable
|
||||
className={cn(
|
||||
'handle absolute top-2.5 -left-4 hidden size-3 cursor-pointer text-text-quaternary',
|
||||
'group-hover:block',
|
||||
)}
|
||||
<>
|
||||
{keyboardSort.announcement}
|
||||
<ReactSortable
|
||||
className="space-y-2"
|
||||
list={listWithIds}
|
||||
disabled={readonly || keyboardSort.isSorting}
|
||||
setList={(list) => {
|
||||
if (
|
||||
keyboardSort.isSorting ||
|
||||
list.every((item, index) => item.id === listWithIds[index]?.id)
|
||||
)
|
||||
return
|
||||
onChange(list.map((item) => item.variable))
|
||||
}}
|
||||
handle=".handle"
|
||||
ghostClass="opacity-50"
|
||||
animation={150}
|
||||
>
|
||||
{keyboardSort.items.map((variable, index) => {
|
||||
const canDrag = (() => {
|
||||
if (readonly) return false
|
||||
return varCount > 1
|
||||
})()
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center gap-x-1', 'group relative')}
|
||||
key={keyboardSort.getItemKey(index)}
|
||||
>
|
||||
{canDrag && (
|
||||
<IconButton
|
||||
{...keyboardSort.getHandleProps(index)}
|
||||
className="handle pointer-events-none absolute top-1 -left-6 size-6 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 focus:pointer-events-auto focus:opacity-100 aria-pressed:pointer-events-auto aria-pressed:opacity-100"
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-draggable size-3" />
|
||||
</IconButton>
|
||||
)}
|
||||
<Input
|
||||
aria-label={variableNameLabel}
|
||||
className="w-30"
|
||||
disabled={readonly}
|
||||
value={variable.variable}
|
||||
onChange={handleVarNameChange(keyboardSort.getItemKey(index))}
|
||||
placeholder={variableNameLabel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</ReactSortable>
|
||||
<VarReferencePicker
|
||||
nodeId={nodeId}
|
||||
readonly={readonly}
|
||||
isShowNodeName
|
||||
className="grow"
|
||||
value={
|
||||
variable.variable_type === VarKindType.constant
|
||||
? variable.value || ''
|
||||
: variable.value_selector || []
|
||||
}
|
||||
isSupportConstantValue={isSupportConstantValue}
|
||||
onChange={handleVarReferenceChange(keyboardSort.getItemKey(index))}
|
||||
defaultVarKindType={variable.variable_type}
|
||||
onlyLeafNodeVar={onlyLeafNodeVar}
|
||||
filterVar={filterVar}
|
||||
isSupportFileVar={isSupportFileVar}
|
||||
/>
|
||||
{!readonly && (
|
||||
<RemoveButton onClick={handleVarRemove(keyboardSort.getItemKey(index))} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</ReactSortable>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default React.memo(VarList)
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
} from '../types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import {
|
||||
Select,
|
||||
SelectItem,
|
||||
@@ -24,12 +25,13 @@ import {
|
||||
SelectPositioner,
|
||||
SelectTrigger,
|
||||
} from '@langgenius/dify-ui/select'
|
||||
import { RiAddLine, RiDeleteBinLine, RiDraggable } from '@remixicon/react'
|
||||
import { RiAddLine, RiDeleteBinLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReactSortable } from 'react-sortablejs'
|
||||
import { useKeyboardSortable } from '@/app/components/base/keyboard-sortable/use-keyboard-sortable'
|
||||
import { VarType } from '../../../types'
|
||||
import { SUB_VARIABLES } from '../../constants'
|
||||
import { useGetAvailableVars } from '../../variable-assigner/hooks'
|
||||
@@ -86,6 +88,15 @@ const ConditionWrap: FC<Props> = ({
|
||||
const getAvailableVars = useGetAvailableVars()
|
||||
|
||||
const [willDeleteCaseId, setWillDeleteCaseId] = useState('')
|
||||
const keyboardSort = useKeyboardSortable({
|
||||
items: cases,
|
||||
onChange: (items) => handleSortCase(items.map((item) => ({ ...item, id: item.case_id }))),
|
||||
disabled: readOnly || isSubVariable,
|
||||
})
|
||||
const sortableCases = useMemo(
|
||||
() => keyboardSort.items.map((item) => ({ ...item, id: item.case_id })),
|
||||
[keyboardSort.items],
|
||||
)
|
||||
const casesLength = cases.length
|
||||
|
||||
const filterNumberVar = useCallback((varPayload: Var) => {
|
||||
@@ -99,15 +110,22 @@ const ConditionWrap: FC<Props> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{keyboardSort.announcement}
|
||||
<ReactSortable
|
||||
list={cases.map((caseItem) => ({ ...caseItem, id: caseItem.case_id }))}
|
||||
setList={handleSortCase}
|
||||
list={sortableCases}
|
||||
setList={(items) => {
|
||||
if (
|
||||
!keyboardSort.isSorting &&
|
||||
items.some((item, index) => item.id !== sortableCases[index]?.id)
|
||||
)
|
||||
handleSortCase(items)
|
||||
}}
|
||||
handle=".handle"
|
||||
ghostClass="bg-components-panel-bg"
|
||||
animation={150}
|
||||
disabled={readOnly || isSubVariable}
|
||||
disabled={readOnly || isSubVariable || keyboardSort.isSorting}
|
||||
>
|
||||
{cases.map((item, index) => (
|
||||
{keyboardSort.items.map((item, index) => (
|
||||
<div key={item.case_id}>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -119,12 +137,14 @@ const ConditionWrap: FC<Props> = ({
|
||||
>
|
||||
{!isSubVariable && (
|
||||
<>
|
||||
<RiDraggable
|
||||
className={cn(
|
||||
'handle absolute top-2 left-1 hidden size-3 cursor-pointer text-text-quaternary',
|
||||
casesLength > 1 && 'group-hover:block',
|
||||
)}
|
||||
/>
|
||||
{!readOnly && casesLength > 1 && (
|
||||
<IconButton
|
||||
{...keyboardSort.getHandleProps(index)}
|
||||
className="handle pointer-events-none absolute top-1 -left-2 z-10 size-6 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 focus:pointer-events-auto focus:opacity-100 aria-pressed:pointer-events-auto aria-pressed:opacity-100"
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-draggable size-3" />
|
||||
</IconButton>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-4 text-[13px] leading-4 font-semibold text-text-secondary',
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { PromptItem } from '../../../../types'
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { PromptRole } from '../../../../types'
|
||||
import ConfigPrompt from '../config-prompt'
|
||||
|
||||
vi.mock('../../../_base/hooks/use-available-var-list', () => ({
|
||||
default: () => ({ availableVars: [], availableNodesWithParent: [] }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../_base/components/prompt/editor', () => ({ default: () => null }))
|
||||
|
||||
vi.mock('../config-prompt-item', () => ({
|
||||
default: ({
|
||||
payload,
|
||||
onPromptChange,
|
||||
}: {
|
||||
payload: PromptItem
|
||||
onPromptChange: (value: string) => void
|
||||
}) => (
|
||||
<textarea
|
||||
aria-label={payload.role}
|
||||
value={payload.text}
|
||||
onChange={(event) => onPromptChange(event.target.value)}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
const initialPrompts: PromptItem[] = [
|
||||
{ id: 'system', role: PromptRole.system, text: 'System instruction' },
|
||||
{ id: 'user', role: PromptRole.user, text: 'User question' },
|
||||
{ id: 'assistant', role: PromptRole.assistant, text: 'Assistant answer' },
|
||||
]
|
||||
|
||||
function Fixture({
|
||||
onChange,
|
||||
initial = initialPrompts,
|
||||
}: {
|
||||
onChange: (items: PromptItem | PromptItem[]) => void
|
||||
initial?: PromptItem[]
|
||||
}) {
|
||||
const [prompts, setPrompts] = useState(initial)
|
||||
return (
|
||||
<ConfigPrompt
|
||||
readOnly={false}
|
||||
nodeId="llm"
|
||||
filterVar={() => true}
|
||||
isChatModel
|
||||
isChatApp={false}
|
||||
payload={prompts}
|
||||
onChange={(items) => {
|
||||
onChange(items)
|
||||
setPrompts(items as PromptItem[])
|
||||
}}
|
||||
isShowContext={false}
|
||||
hasSetBlockStatus={{ context: false, history: false, query: false }}
|
||||
handleAddVariable={vi.fn()}
|
||||
modelConfig={{ provider: 'openai', name: 'gpt-4', mode: 'chat', completion_params: {} }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
it('reorders messages without moving past the leading system prompt', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
renderWorkflowComponent(<Fixture onChange={onChange} />)
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
expect(screen.getAllByRole('button', { pressed: false })).toHaveLength(2)
|
||||
const handle = screen.getAllByRole('button', { pressed: false })[0]!
|
||||
for (let index = 0; index < 10 && document.activeElement !== handle; index++) await user.tab()
|
||||
expect(handle).toHaveFocus()
|
||||
await user.keyboard('{Enter}{ArrowUp}')
|
||||
expect(
|
||||
screen.getAllByRole('textbox').map((input) => (input as HTMLTextAreaElement).value),
|
||||
).toEqual(initialPrompts.map((item) => item.text))
|
||||
await user.keyboard('{ArrowDown}')
|
||||
expect(
|
||||
screen.getAllByRole('textbox').map((input) => (input as HTMLTextAreaElement).value),
|
||||
).toEqual(['System instruction', 'Assistant answer', 'User question'])
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
await user.keyboard('{Enter}')
|
||||
expect(onChange).toHaveBeenCalledExactlyOnceWith([
|
||||
initialPrompts[0],
|
||||
initialPrompts[2],
|
||||
initialPrompts[1],
|
||||
])
|
||||
expect(screen.getAllByRole('button', { pressed: false })[1]).toHaveFocus()
|
||||
})
|
||||
|
||||
it('persists unique IDs for default prompts once and retains them when sorting', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn<(items: PromptItem | PromptItem[]) => void>()
|
||||
const initial = initialPrompts.map((item) => ({ role: item.role, text: item.text }))
|
||||
renderWorkflowComponent(<Fixture onChange={onChange} initial={initial} />)
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
const initialized = onChange.mock.calls[0]![0] as PromptItem[]
|
||||
expect(initialized).toEqual(initial.map((item) => ({ ...item, id: expect.any(String) })))
|
||||
expect(new Set(initialized.map((item) => item.id)).size).toBe(initial.length)
|
||||
|
||||
const handle = screen.getAllByRole('button', { pressed: false })[0]!
|
||||
for (let index = 0; index < 10 && document.activeElement !== handle; index++) await user.tab()
|
||||
expect(handle).toHaveFocus()
|
||||
await user.keyboard('{Enter}{ArrowDown}{Escape}')
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
await user.keyboard('{Enter}{ArrowDown}{Enter}')
|
||||
expect(onChange).toHaveBeenCalledTimes(2)
|
||||
expect(onChange).toHaveBeenLastCalledWith([initialized[0], initialized[2], initialized[1]])
|
||||
})
|
||||
@@ -2,13 +2,14 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ModelConfig, PromptItem, ValueSelector, Var, Variable } from '../../../types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReactSortable } from 'react-sortablejs'
|
||||
import { v4 as uuid4 } from 'uuid'
|
||||
import { DragHandle } from '@/app/components/base/icons/src/vender/line/others'
|
||||
import { useKeyboardSortable } from '@/app/components/base/keyboard-sortable/use-keyboard-sortable'
|
||||
import AddButton from '@/app/components/workflow/nodes/_base/components/add-button'
|
||||
import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor'
|
||||
import { useWorkflowStore } from '../../../store'
|
||||
@@ -54,19 +55,25 @@ const ConfigPrompt: FC<Props> = ({
|
||||
const { t } = useTranslation()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { setControlPromptEditorRerenderKey } = workflowStore.getState()
|
||||
const payloadWithIds =
|
||||
isChatModel && Array.isArray(payload)
|
||||
? payload.map((item) => {
|
||||
const id = uuid4()
|
||||
return {
|
||||
id: item.id || id,
|
||||
p: {
|
||||
...item,
|
||||
id: item.id || id,
|
||||
},
|
||||
}
|
||||
})
|
||||
: []
|
||||
const prompts = useMemo(
|
||||
() => (isChatModel && Array.isArray(payload) ? payload : []),
|
||||
[isChatModel, payload],
|
||||
)
|
||||
const keyboardSort = useKeyboardSortable({
|
||||
items: prompts,
|
||||
onChange,
|
||||
disabled: readOnly,
|
||||
minIndex: prompts[0]?.role === PromptRole.system ? 1 : 0,
|
||||
getItemLabel: (item) => item.role || '',
|
||||
})
|
||||
const payloadWithIds = useMemo(
|
||||
() =>
|
||||
keyboardSort.items.map((item) => {
|
||||
const id = item.id || uuid4()
|
||||
return { id, p: { ...item, id } }
|
||||
}),
|
||||
[keyboardSort.items],
|
||||
)
|
||||
const { availableVars, availableNodesWithParent } = useAvailableVarList(nodeId, {
|
||||
onlyLeafNodeVar: false,
|
||||
filterVar,
|
||||
@@ -175,13 +182,21 @@ const ConfigPrompt: FC<Props> = ({
|
||||
})()
|
||||
return (
|
||||
<div>
|
||||
{keyboardSort.announcement}
|
||||
{isChatModel && Array.isArray(payload) ? (
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<ReactSortable
|
||||
className="space-y-1"
|
||||
list={payloadWithIds}
|
||||
disabled={readOnly || keyboardSort.isSorting}
|
||||
setList={(list) => {
|
||||
if (
|
||||
keyboardSort.isSorting ||
|
||||
(prompts.every((item) => !!item.id) &&
|
||||
list.every((item, index) => item.id === payloadWithIds[index]?.id))
|
||||
)
|
||||
return
|
||||
if (
|
||||
(payload as PromptItem[])?.[0]?.role === PromptRole.system &&
|
||||
list[0]!.p?.role !== PromptRole.system
|
||||
@@ -194,7 +209,7 @@ const ConfigPrompt: FC<Props> = ({
|
||||
ghostClass="opacity-50"
|
||||
animation={150}
|
||||
>
|
||||
{(payload as PromptItem[]).map((item, index) => {
|
||||
{keyboardSort.items.map((item, index) => {
|
||||
const canDrag = (() => {
|
||||
if (readOnly) return false
|
||||
|
||||
@@ -203,9 +218,17 @@ const ConfigPrompt: FC<Props> = ({
|
||||
return true
|
||||
})()
|
||||
return (
|
||||
<div key={item.id || index} className="group relative">
|
||||
<div key={item.id || keyboardSort.getItemKey(index)} className="group relative">
|
||||
{canDrag && (
|
||||
<DragHandle className="absolute top-2 -left-3.5 hidden h-3.5 w-3.5 text-text-quaternary group-hover:block" />
|
||||
<IconButton
|
||||
{...keyboardSort.getHandleProps(index)}
|
||||
className="handle pointer-events-none absolute top-1 -left-6 size-6 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 focus:pointer-events-auto focus:opacity-100 aria-pressed:pointer-events-auto aria-pressed:opacity-100"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="i-custom-vender-line-others-drag-handle size-3.5"
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
<ConfigPromptItem
|
||||
instanceId={
|
||||
@@ -222,13 +245,17 @@ const ConfigPrompt: FC<Props> = ({
|
||||
readOnly={readOnly}
|
||||
id={item.id!}
|
||||
nodeId={nodeId}
|
||||
handleChatModeMessageRoleChange={handleChatModeMessageRoleChange(index)}
|
||||
handleChatModeMessageRoleChange={handleChatModeMessageRoleChange(
|
||||
keyboardSort.getItemKey(index),
|
||||
)}
|
||||
isChatModel={isChatModel}
|
||||
isChatApp={isChatApp}
|
||||
payload={item}
|
||||
onPromptChange={handleChatModePromptChange(index)}
|
||||
onEditionTypeChange={handleChatModeEditionTypeChange(index)}
|
||||
onRemove={handleRemove(index)}
|
||||
onPromptChange={handleChatModePromptChange(keyboardSort.getItemKey(index))}
|
||||
onEditionTypeChange={handleChatModeEditionTypeChange(
|
||||
keyboardSort.getItemKey(index),
|
||||
)}
|
||||
onRemove={handleRemove(keyboardSort.getItemKey(index))}
|
||||
isShowContext={isShowContext}
|
||||
hasSetBlockStatus={hasSetBlockStatus}
|
||||
availableVars={availableVars}
|
||||
|
||||
+33
@@ -1,6 +1,7 @@
|
||||
import type { Topic } from '../../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { useEdgesInteractions } from '../../../../hooks/use-edges-interactions'
|
||||
import ClassList from '../class-list'
|
||||
|
||||
@@ -102,6 +103,38 @@ describe('question-classifier/class-list', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('confirms keyboard sorting through the branch-aware sort callback', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleSortTopic = vi.fn()
|
||||
const onChange = vi.fn()
|
||||
const topics = [createTopic(), createTopic({ id: 'topic-2', name: 'Refunds' })]
|
||||
function Fixture() {
|
||||
const [list, setList] = useState(topics)
|
||||
return (
|
||||
<ClassList
|
||||
nodeId="node-1"
|
||||
list={list}
|
||||
onChange={onChange}
|
||||
filterVar={() => true}
|
||||
handleSortTopic={(items) => {
|
||||
handleSortTopic(items)
|
||||
setList(items)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
render(<Fixture />)
|
||||
const handle = screen.getAllByRole('button', { pressed: false })[0]!
|
||||
for (let index = 0; index < 10 && document.activeElement !== handle; index++) await user.tab()
|
||||
expect(handle).toHaveFocus()
|
||||
await user.keyboard('{Enter}{ArrowDown}')
|
||||
expect(screen.getByText('1:Refunds')).toBeInTheDocument()
|
||||
expect(handleSortTopic).not.toHaveBeenCalled()
|
||||
await user.keyboard('{Enter}')
|
||||
expect(handleSortTopic).toHaveBeenCalledExactlyOnceWith([topics[1], topics[0]])
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides drag and add affordances when readonly', () => {
|
||||
const { container } = render(
|
||||
<ClassList
|
||||
|
||||
@@ -3,14 +3,15 @@ import type { FC } from 'react'
|
||||
import type { Topic } from '@/app/components/workflow/nodes/question-classifier/types'
|
||||
import type { ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RiDraggable } from '@remixicon/react'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReactSortable } from 'react-sortablejs'
|
||||
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
import { useKeyboardSortable } from '@/app/components/base/keyboard-sortable/use-keyboard-sortable'
|
||||
import { useEdgesInteractions } from '../../../hooks/use-edges-interactions'
|
||||
import AddButton from '../../_base/components/add-button'
|
||||
import { useInlineLabelHintDismissed } from '../storage'
|
||||
@@ -82,6 +83,17 @@ const ClassList: FC<Props> = ({
|
||||
[list, onChange, handleEdgeDeleteByDeleteBranch, nodeId],
|
||||
)
|
||||
|
||||
const keyboardSort = useKeyboardSortable({
|
||||
items: list,
|
||||
onChange: handleSortTopic,
|
||||
disabled: readonly,
|
||||
getItemLabel: (item) => item.label || item.name,
|
||||
})
|
||||
const sortableTopics = useMemo(
|
||||
() => keyboardSort.items.map((item) => ({ ...item })),
|
||||
[keyboardSort.items],
|
||||
)
|
||||
|
||||
const topicCount = list.length
|
||||
|
||||
useEffect(() => {
|
||||
@@ -108,6 +120,7 @@ const ClassList: FC<Props> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{keyboardSort.announcement}
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
@@ -136,15 +149,21 @@ const ClassList: FC<Props> = ({
|
||||
{!collapsed && (
|
||||
<div ref={listContainerRef} className="overflow-y-visible pl-3">
|
||||
<ReactSortable
|
||||
list={list.map((item) => ({ ...item }))}
|
||||
setList={handleSortTopic}
|
||||
list={sortableTopics}
|
||||
setList={(items) => {
|
||||
if (
|
||||
!keyboardSort.isSorting &&
|
||||
items.some((item, index) => item.id !== sortableTopics[index]?.id)
|
||||
)
|
||||
handleSortTopic(items)
|
||||
}}
|
||||
handle=".handle"
|
||||
ghostClass="bg-components-panel-bg"
|
||||
animation={150}
|
||||
disabled={readonly}
|
||||
disabled={readonly || keyboardSort.isSorting}
|
||||
className="space-y-2"
|
||||
>
|
||||
{list.map((item, index) => {
|
||||
{keyboardSort.items.map((item, index) => {
|
||||
const canDrag = !readonly && topicCount >= 2
|
||||
return (
|
||||
<div
|
||||
@@ -159,21 +178,23 @@ const ClassList: FC<Props> = ({
|
||||
>
|
||||
<div>
|
||||
{canDrag && (
|
||||
<RiDraggable
|
||||
className={cn(
|
||||
'handle absolute top-3 left-2 hidden size-3 cursor-pointer text-text-tertiary',
|
||||
'group-hover:block',
|
||||
)}
|
||||
/>
|
||||
<IconButton
|
||||
{...keyboardSort.getHandleProps(index)}
|
||||
className="handle pointer-events-none absolute top-1.5 left-0.5 z-10 size-6 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 focus:pointer-events-auto focus:opacity-100 aria-pressed:pointer-events-auto aria-pressed:opacity-100"
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-draggable size-3" />
|
||||
</IconButton>
|
||||
)}
|
||||
<Item
|
||||
className={cn(canDrag && 'handle')}
|
||||
headerClassName={cn(canDrag && 'cursor-grab group-hover:pl-5')}
|
||||
headerClassName={cn(
|
||||
canDrag && 'cursor-grab group-focus-within:pl-5 group-hover:pl-5',
|
||||
)}
|
||||
nodeId={nodeId}
|
||||
key={list[index]!.id}
|
||||
key={item.id}
|
||||
payload={item}
|
||||
onChange={handleClassChange(index)}
|
||||
onRemove={handleRemoveClass(index)}
|
||||
onChange={handleClassChange(keyboardSort.getItemKey(index))}
|
||||
onRemove={handleRemoveClass(keyboardSort.getItemKey(index))}
|
||||
index={index + 1}
|
||||
readonly={readonly}
|
||||
filterVar={filterVar}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import VarList from '../var-list'
|
||||
|
||||
vi.mock('@/app/components/app/configuration/config-var/config-modal', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
it('tabs to the reorder handle before the hovered variable edit and remove actions', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<VarList
|
||||
readonly={false}
|
||||
list={['alpha', 'beta'].map((variable) => ({
|
||||
variable,
|
||||
label: variable,
|
||||
type: InputVarType.textInput,
|
||||
required: false,
|
||||
}))}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
await user.hover(screen.getAllByText('alpha')[0]!)
|
||||
const handles = screen.getAllByRole('button', { pressed: false })
|
||||
|
||||
await user.tab()
|
||||
expect(handles[0]).toHaveFocus()
|
||||
await user.tab()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.edit' })).toHaveFocus()
|
||||
await user.tab()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.remove' })).toHaveFocus()
|
||||
await user.tab()
|
||||
expect(handles[1]).toHaveFocus()
|
||||
})
|
||||
@@ -61,7 +61,11 @@ const VarItem: FC<Props> = ({
|
||||
>
|
||||
<div className="flex w-0 grow items-center space-x-1">
|
||||
<Variable02
|
||||
className={cn('size-3.5 text-text-accent', canDrag && 'group-hover:opacity-0')}
|
||||
className={cn(
|
||||
'size-3.5 text-text-accent',
|
||||
canDrag &&
|
||||
'group-hover:opacity-0 group-has-[.handle:focus]:opacity-0 group-has-[.handle[aria-pressed=true]]:opacity-0',
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
title={payload.variable}
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
import type { FC } from 'react'
|
||||
import type { InputVar, MoreInfo } from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiDraggable } from '@remixicon/react'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReactSortable } from 'react-sortablejs'
|
||||
import { useKeyboardSortable } from '@/app/components/base/keyboard-sortable/use-keyboard-sortable'
|
||||
import { ChangeType } from '@/app/components/workflow/types'
|
||||
import { hasDuplicateStr } from '@/utils/var'
|
||||
import VarItem from './var-item'
|
||||
@@ -74,15 +75,20 @@ const VarList: FC<Props> = ({ readonly, list, onChange }) => {
|
||||
[list, onChange],
|
||||
)
|
||||
|
||||
const keyboardSort = useKeyboardSortable({
|
||||
items: list,
|
||||
onChange,
|
||||
disabled: readonly,
|
||||
getItemLabel: (item) => item.variable,
|
||||
})
|
||||
|
||||
const listWithIds = useMemo(
|
||||
() =>
|
||||
list.map((item) => {
|
||||
return {
|
||||
id: item.variable,
|
||||
variable: { ...item },
|
||||
}
|
||||
}),
|
||||
[list],
|
||||
keyboardSort.items.map((item) => ({
|
||||
id: item.variable,
|
||||
variable: { ...item },
|
||||
})),
|
||||
[keyboardSort.items],
|
||||
)
|
||||
|
||||
const varCount = list.length
|
||||
@@ -98,38 +104,47 @@ const VarList: FC<Props> = ({ readonly, list, onChange }) => {
|
||||
const canDrag = !readonly && varCount > 1
|
||||
|
||||
return (
|
||||
<ReactSortable
|
||||
className="space-y-1"
|
||||
list={listWithIds}
|
||||
setList={(list) => {
|
||||
onChange(list.map((item) => item.variable))
|
||||
}}
|
||||
handle=".handle"
|
||||
ghostClass="opacity-50"
|
||||
animation={150}
|
||||
>
|
||||
{listWithIds.map((itemWithId, index) => (
|
||||
<div key={itemWithId.id} className="group relative">
|
||||
<VarItem
|
||||
className={cn(canDrag && 'handle')}
|
||||
readonly={readonly}
|
||||
payload={itemWithId.variable}
|
||||
onChange={handleVarChange(index)}
|
||||
onRemove={handleVarRemove(index)}
|
||||
varKeys={list.map((item) => item.variable)}
|
||||
canDrag={canDrag}
|
||||
/>
|
||||
{canDrag && (
|
||||
<RiDraggable
|
||||
className={cn(
|
||||
'handle absolute top-2.5 left-3 hidden size-3 cursor-pointer text-text-tertiary',
|
||||
'group-hover:block',
|
||||
)}
|
||||
<>
|
||||
{keyboardSort.announcement}
|
||||
<ReactSortable
|
||||
className="space-y-1"
|
||||
list={listWithIds}
|
||||
disabled={readonly || keyboardSort.isSorting}
|
||||
setList={(list) => {
|
||||
if (
|
||||
keyboardSort.isSorting ||
|
||||
list.every((item, index) => item.id === listWithIds[index]?.id)
|
||||
)
|
||||
return
|
||||
onChange(list.map((item) => item.variable))
|
||||
}}
|
||||
handle=".handle"
|
||||
ghostClass="opacity-50"
|
||||
animation={150}
|
||||
>
|
||||
{listWithIds.map((itemWithId, index) => (
|
||||
<div key={itemWithId.id} className="group relative">
|
||||
{canDrag && (
|
||||
<IconButton
|
||||
{...keyboardSort.getHandleProps(index)}
|
||||
className="handle pointer-events-none absolute top-1 left-1.5 size-6 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 focus:pointer-events-auto focus:opacity-100 aria-pressed:pointer-events-auto aria-pressed:opacity-100"
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-draggable size-3" />
|
||||
</IconButton>
|
||||
)}
|
||||
<VarItem
|
||||
className={cn(canDrag && 'handle')}
|
||||
readonly={readonly}
|
||||
payload={itemWithId.variable}
|
||||
onChange={handleVarChange(keyboardSort.getItemKey(index))}
|
||||
onRemove={handleVarRemove(keyboardSort.getItemKey(index))}
|
||||
varKeys={list.map((item) => item.variable)}
|
||||
canDrag={canDrag}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</ReactSortable>
|
||||
</div>
|
||||
))}
|
||||
</ReactSortable>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default React.memo(VarList)
|
||||
@@ -569,6 +569,13 @@
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workspace": "WORKSPACE",
|
||||
"sort.cancelled": "تم إلغاء ترتيب {{item}}.",
|
||||
"sort.finished": "تم وضع {{item}} في الموضع {{position}} من {{count}}.",
|
||||
"sort.handle": "إعادة ترتيب {{item}}",
|
||||
"sort.instructions": "اضغط على المسافة أو Enter لبدء الترتيب. استخدم السهمين للأعلى والأسفل للتحريك. اضغط على المسافة أو Enter للتأكيد أو Escape للإلغاء.",
|
||||
"sort.item": "العنصر {{position}}",
|
||||
"sort.moved": "تم نقل {{item}} إلى الموضع {{position}} من {{count}}.",
|
||||
"sort.started": "تم اختيار {{item}}، في الموضع {{position}} من {{count}}.",
|
||||
"stepByStepTour.completion.description": "لقد اطّلعت على الأساسيات. حان وقت بناء شيء ما.",
|
||||
"stepByStepTour.completion.dismiss": "إغلاق",
|
||||
"stepByStepTour.completion.label": "اكتملت جولة Step-by-step Tour",
|
||||
|
||||
@@ -569,6 +569,13 @@
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workspace": "WORKSPACE",
|
||||
"sort.cancelled": "Sortierung von {{item}} abgebrochen.",
|
||||
"sort.finished": "{{item}} an Position {{position}} von {{count}} abgelegt.",
|
||||
"sort.handle": "{{item}} umsortieren",
|
||||
"sort.instructions": "Leertaste oder Enter startet die Sortierung. Mit Pfeil nach oben und unten verschieben. Mit Leertaste oder Enter bestätigen, mit Escape abbrechen.",
|
||||
"sort.item": "Element {{position}}",
|
||||
"sort.moved": "{{item}} auf Position {{position}} von {{count}} verschoben.",
|
||||
"sort.started": "{{item}} aufgenommen, Position {{position}} von {{count}}.",
|
||||
"stepByStepTour.completion.description": "Du hast die Grundlagen gesehen. Zeit, etwas zu bauen.",
|
||||
"stepByStepTour.completion.dismiss": "Schließen",
|
||||
"stepByStepTour.completion.label": "Step-by-step Tour abgeschlossen",
|
||||
|
||||
@@ -569,6 +569,13 @@
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workspace": "WORKSPACE",
|
||||
"sort.cancelled": "Sorting {{item}} cancelled.",
|
||||
"sort.finished": "Placed {{item}} at position {{position}} of {{count}}.",
|
||||
"sort.handle": "Reorder {{item}}",
|
||||
"sort.instructions": "Press Space or Enter to start sorting. Use the up and down arrows to move. Press Space or Enter to confirm, or Escape to cancel.",
|
||||
"sort.item": "Item {{position}}",
|
||||
"sort.moved": "Moved {{item}} to position {{position}} of {{count}}.",
|
||||
"sort.started": "Picked up {{item}}, position {{position}} of {{count}}.",
|
||||
"stepByStepTour.completion.description": "You’ve seen the essentials. Time to build something.",
|
||||
"stepByStepTour.completion.dismiss": "Dismiss",
|
||||
"stepByStepTour.completion.label": "Step-by-step Tour completed",
|
||||
|
||||
@@ -569,6 +569,13 @@
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workspace": "WORKSPACE",
|
||||
"sort.cancelled": "Ordenación de {{item}} cancelada.",
|
||||
"sort.finished": "{{item}} colocado en la posición {{position}} de {{count}}.",
|
||||
"sort.handle": "Reordenar {{item}}",
|
||||
"sort.instructions": "Pulsa Espacio o Intro para empezar a ordenar. Usa las flechas arriba y abajo para mover. Pulsa Espacio o Intro para confirmar, o Escape para cancelar.",
|
||||
"sort.item": "Elemento {{position}}",
|
||||
"sort.moved": "{{item}} movido a la posición {{position}} de {{count}}.",
|
||||
"sort.started": "{{item}} seleccionado, posición {{position}} de {{count}}.",
|
||||
"stepByStepTour.completion.description": "Ya viste lo esencial. Es hora de crear algo.",
|
||||
"stepByStepTour.completion.dismiss": "Descartar",
|
||||
"stepByStepTour.completion.label": "Step-by-step Tour completado",
|
||||
|
||||
@@ -569,6 +569,13 @@
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workspace": "WORKSPACE",
|
||||
"sort.cancelled": "مرتبسازی {{item}} لغو شد.",
|
||||
"sort.finished": "{{item}} در جایگاه {{position}} از {{count}} قرار گرفت.",
|
||||
"sort.handle": "تغییر ترتیب {{item}}",
|
||||
"sort.instructions": "برای شروع مرتبسازی Space یا Enter را فشار دهید. با پیکانهای بالا و پایین جابهجا کنید. برای تأیید Space یا Enter و برای لغو Escape را فشار دهید.",
|
||||
"sort.item": "مورد {{position}}",
|
||||
"sort.moved": "{{item}} به جایگاه {{position}} از {{count}} منتقل شد.",
|
||||
"sort.started": "{{item}} انتخاب شد، جایگاه {{position}} از {{count}}.",
|
||||
"stepByStepTour.completion.description": "موارد اصلی را دیدید. وقت ساختن چیزی تازه است.",
|
||||
"stepByStepTour.completion.dismiss": "بستن",
|
||||
"stepByStepTour.completion.label": "Step-by-step Tour کامل شد",
|
||||
|
||||
@@ -569,6 +569,13 @@
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workspace": "WORKSPACE",
|
||||
"sort.cancelled": "Tri de {{item}} annulé.",
|
||||
"sort.finished": "{{item}} placé à la position {{position}} sur {{count}}.",
|
||||
"sort.handle": "Réordonner {{item}}",
|
||||
"sort.instructions": "Appuyez sur Espace ou Entrée pour commencer le tri. Utilisez les flèches haut et bas pour déplacer. Confirmez avec Espace ou Entrée, ou annulez avec Échap.",
|
||||
"sort.item": "Élément {{position}}",
|
||||
"sort.moved": "{{item}} déplacé à la position {{position}} sur {{count}}.",
|
||||
"sort.started": "{{item}} sélectionné, position {{position}} sur {{count}}.",
|
||||
"stepByStepTour.completion.description": "Vous avez vu l’essentiel. Il est temps de créer quelque chose.",
|
||||
"stepByStepTour.completion.dismiss": "Fermer",
|
||||
"stepByStepTour.completion.label": "Step-by-step Tour terminé",
|
||||
|
||||
@@ -569,6 +569,13 @@
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workspace": "WORKSPACE",
|
||||
"sort.cancelled": "{{item}} का क्रम बदलना रद्द किया गया।",
|
||||
"sort.finished": "{{item}} को {{count}} में से स्थान {{position}} पर रखा गया।",
|
||||
"sort.handle": "{{item}} का क्रम बदलें",
|
||||
"sort.instructions": "क्रम बदलना शुरू करने के लिए Space या Enter दबाएँ। ऊपर और नीचे तीर से स्थान बदलें। पुष्टि के लिए Space या Enter, रद्द करने के लिए Escape दबाएँ।",
|
||||
"sort.item": "आइटम {{position}}",
|
||||
"sort.moved": "{{item}} को {{count}} में से स्थान {{position}} पर ले जाया गया।",
|
||||
"sort.started": "{{item}} चुना गया, {{count}} में से स्थान {{position}}।",
|
||||
"stepByStepTour.completion.description": "आपने ज़रूरी बातें देख ली हैं। अब कुछ बनाने का समय है।",
|
||||
"stepByStepTour.completion.dismiss": "बंद करें",
|
||||
"stepByStepTour.completion.label": "Step-by-step Tour पूरा हुआ",
|
||||
|
||||
@@ -569,6 +569,13 @@
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workspace": "WORKSPACE",
|
||||
"sort.cancelled": "Pengurutan {{item}} dibatalkan.",
|
||||
"sort.finished": "{{item}} ditempatkan pada posisi {{position}} dari {{count}}.",
|
||||
"sort.handle": "Urutkan ulang {{item}}",
|
||||
"sort.instructions": "Tekan Spasi atau Enter untuk mulai mengurutkan. Gunakan panah atas dan bawah untuk memindahkan. Tekan Spasi atau Enter untuk konfirmasi, atau Escape untuk membatalkan.",
|
||||
"sort.item": "Item {{position}}",
|
||||
"sort.moved": "{{item}} dipindahkan ke posisi {{position}} dari {{count}}.",
|
||||
"sort.started": "{{item}} dipilih, posisi {{position}} dari {{count}}.",
|
||||
"stepByStepTour.completion.description": "Anda sudah melihat hal-hal penting. Saatnya mulai membangun sesuatu.",
|
||||
"stepByStepTour.completion.dismiss": "Tutup",
|
||||
"stepByStepTour.completion.label": "Step-by-step Tour selesai",
|
||||
|
||||
@@ -569,6 +569,13 @@
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workspace": "WORKSPACE",
|
||||
"sort.cancelled": "Riordinamento di {{item}} annullato.",
|
||||
"sort.finished": "{{item}} posizionato al posto {{position}} di {{count}}.",
|
||||
"sort.handle": "Riordina {{item}}",
|
||||
"sort.instructions": "Premi Spazio o Invio per iniziare a ordinare. Usa le frecce su e giù per spostare. Premi Spazio o Invio per confermare, oppure Esc per annullare.",
|
||||
"sort.item": "Elemento {{position}}",
|
||||
"sort.moved": "{{item}} spostato alla posizione {{position}} di {{count}}.",
|
||||
"sort.started": "{{item}} selezionato, posizione {{position}} di {{count}}.",
|
||||
"stepByStepTour.completion.description": "Hai visto gli elementi essenziali. È il momento di creare qualcosa.",
|
||||
"stepByStepTour.completion.dismiss": "Chiudi",
|
||||
"stepByStepTour.completion.label": "Step-by-step Tour completato",
|
||||
|
||||
@@ -569,6 +569,13 @@
|
||||
"settings.swaggerAPIAsTool": "Swagger API をツールとして利用",
|
||||
"settings.trigger": "トリガー",
|
||||
"settings.workspace": "ワークスペース",
|
||||
"sort.cancelled": "{{item}} の並べ替えをキャンセルしました。",
|
||||
"sort.finished": "{{item}} を {{count}} 件中 {{position}} 番目に確定しました。",
|
||||
"sort.handle": "{{item}} を並べ替え",
|
||||
"sort.instructions": "Space または Enter で並べ替えを開始し、上下矢印キーで移動します。Space または Enter で確定、Escape でキャンセルします。",
|
||||
"sort.item": "項目 {{position}}",
|
||||
"sort.moved": "{{item}} を {{count}} 件中 {{position}} 番目に移動しました。",
|
||||
"sort.started": "{{item}} を選択しました。{{count}} 件中 {{position}} 番目です。",
|
||||
"stepByStepTour.completion.description": "基本を確認しました。さあ、作り始めましょう。",
|
||||
"stepByStepTour.completion.dismiss": "閉じる",
|
||||
"stepByStepTour.completion.label": "Step-by-step Tour が完了しました",
|
||||
|
||||
@@ -569,6 +569,13 @@
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workspace": "WORKSPACE",
|
||||
"sort.cancelled": "{{item}} 순서 변경을 취소했습니다.",
|
||||
"sort.finished": "{{item}} 항목을 {{count}}개 중 {{position}}번째에 배치했습니다.",
|
||||
"sort.handle": "{{item}} 순서 변경",
|
||||
"sort.instructions": "Space 또는 Enter로 정렬을 시작하고 위아래 화살표로 이동하세요. Space 또는 Enter로 확정하거나 Escape로 취소하세요.",
|
||||
"sort.item": "항목 {{position}}",
|
||||
"sort.moved": "{{item}} 항목을 {{count}}개 중 {{position}}번째로 이동했습니다.",
|
||||
"sort.started": "{{item}} 선택됨. {{count}}개 중 {{position}}번째입니다.",
|
||||
"stepByStepTour.completion.description": "핵심 내용을 모두 살펴봤습니다. 이제 만들어 볼 시간입니다.",
|
||||
"stepByStepTour.completion.dismiss": "닫기",
|
||||
"stepByStepTour.completion.label": "Step-by-step Tour 완료",
|
||||
|
||||
@@ -569,6 +569,13 @@
|
||||
"settings.swaggerAPIAsTool": "Swagger API ເປັນເຄື່ອງມື",
|
||||
"settings.trigger": "ຕົວຈຸດຊະນວນ (Trigger)",
|
||||
"settings.workspace": "ພື້ນທີ່ເຮັດວຽກ",
|
||||
"sort.cancelled": "ຍົກເລີກການຈັດລຳດັບ {{item}} ແລ້ວ.",
|
||||
"sort.finished": "ວາງ {{item}} ຢູ່ລຳດັບ {{position}} ຈາກ {{count}} ແລ້ວ.",
|
||||
"sort.handle": "ຈັດລຳດັບ {{item}} ໃໝ່",
|
||||
"sort.instructions": "ກົດ Space ຫຼື Enter ເພື່ອເລີ່ມຈັດລຳດັບ. ໃຊ້ລູກສອນຂຶ້ນ ແລະ ລົງເພື່ອຍ້າຍ. ກົດ Space ຫຼື Enter ເພື່ອຢືນຢັນ ຫຼື Escape ເພື່ອຍົກເລີກ.",
|
||||
"sort.item": "ລາຍການ {{position}}",
|
||||
"sort.moved": "ຍ້າຍ {{item}} ໄປລຳດັບ {{position}} ຈາກ {{count}} ແລ້ວ.",
|
||||
"sort.started": "ເລືອກ {{item}} ແລ້ວ, ລຳດັບ {{position}} ຈາກ {{count}}.",
|
||||
"stepByStepTour.completion.description": "ທ່ານໄດ້ເຫັນສິ່ງທີ່ຈຳເປັນແລ້ວ. ເຖິງເວລາສ້າງບາງສິ່ງແລ້ວ.",
|
||||
"stepByStepTour.completion.dismiss": "ປິດອອກ",
|
||||
"stepByStepTour.completion.label": "ການແນະນຳເທື່ອລະຂັ້ນຕອນສຳເລັດແລ້ວ",
|
||||
|
||||
@@ -569,6 +569,13 @@
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workspace": "WORKSPACE",
|
||||
"sort.cancelled": "Sorteren van {{item}} geannuleerd.",
|
||||
"sort.finished": "{{item}} neergezet op positie {{position}} van {{count}}.",
|
||||
"sort.handle": "{{item}} opnieuw ordenen",
|
||||
"sort.instructions": "Druk op Spatie of Enter om te sorteren. Gebruik de pijlen omhoog en omlaag om te verplaatsen. Bevestig met Spatie of Enter, of annuleer met Escape.",
|
||||
"sort.item": "Item {{position}}",
|
||||
"sort.moved": "{{item}} verplaatst naar positie {{position}} van {{count}}.",
|
||||
"sort.started": "{{item}} opgepakt, positie {{position}} van {{count}}.",
|
||||
"stepByStepTour.completion.description": "Je hebt de basis gezien. Tijd om iets te bouwen.",
|
||||
"stepByStepTour.completion.dismiss": "Sluiten",
|
||||
"stepByStepTour.completion.label": "Step-by-step Tour voltooid",
|
||||
|
||||
@@ -569,6 +569,13 @@
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workspace": "WORKSPACE",
|
||||
"sort.cancelled": "Anulowano sortowanie {{item}}.",
|
||||
"sort.finished": "Umieszczono {{item}} na pozycji {{position}} z {{count}}.",
|
||||
"sort.handle": "Zmień kolejność {{item}}",
|
||||
"sort.instructions": "Naciśnij Spację lub Enter, aby rozpocząć sortowanie. Przesuwaj strzałkami w górę i w dół. Potwierdź Spacją lub Enter albo anuluj klawiszem Escape.",
|
||||
"sort.item": "Element {{position}}",
|
||||
"sort.moved": "Przeniesiono {{item}} na pozycję {{position}} z {{count}}.",
|
||||
"sort.started": "Wybrano {{item}}, pozycja {{position}} z {{count}}.",
|
||||
"stepByStepTour.completion.description": "Znasz już najważniejsze elementy. Czas coś zbudować.",
|
||||
"stepByStepTour.completion.dismiss": "Zamknij",
|
||||
"stepByStepTour.completion.label": "Step-by-step Tour ukończony",
|
||||
|
||||
@@ -569,6 +569,13 @@
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workspace": "WORKSPACE",
|
||||
"sort.cancelled": "Ordenação de {{item}} cancelada.",
|
||||
"sort.finished": "{{item}} colocado na posição {{position}} de {{count}}.",
|
||||
"sort.handle": "Reordenar {{item}}",
|
||||
"sort.instructions": "Pressione Espaço ou Enter para começar a ordenar. Use as setas para cima e para baixo para mover. Pressione Espaço ou Enter para confirmar, ou Escape para cancelar.",
|
||||
"sort.item": "Item {{position}}",
|
||||
"sort.moved": "{{item}} movido para a posição {{position}} de {{count}}.",
|
||||
"sort.started": "{{item}} selecionado, posição {{position}} de {{count}}.",
|
||||
"stepByStepTour.completion.description": "Você já viu o essencial. Hora de criar algo.",
|
||||
"stepByStepTour.completion.dismiss": "Dispensar",
|
||||
"stepByStepTour.completion.label": "Step-by-step Tour concluído",
|
||||
|
||||
@@ -569,6 +569,13 @@
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workspace": "WORKSPACE",
|
||||
"sort.cancelled": "Sortarea pentru {{item}} a fost anulată.",
|
||||
"sort.finished": "{{item}} plasat la poziția {{position}} din {{count}}.",
|
||||
"sort.handle": "Reordonează {{item}}",
|
||||
"sort.instructions": "Apasă Spațiu sau Enter pentru a începe sortarea. Folosește săgețile sus și jos pentru a muta. Confirmă cu Spațiu sau Enter ori anulează cu Escape.",
|
||||
"sort.item": "Elementul {{position}}",
|
||||
"sort.moved": "{{item}} mutat la poziția {{position}} din {{count}}.",
|
||||
"sort.started": "{{item}} selectat, poziția {{position}} din {{count}}.",
|
||||
"stepByStepTour.completion.description": "Ai văzut elementele esențiale. Este timpul să construiești ceva.",
|
||||
"stepByStepTour.completion.dismiss": "Închide",
|
||||
"stepByStepTour.completion.label": "Step-by-step Tour finalizat",
|
||||
|
||||
@@ -569,6 +569,13 @@
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workspace": "WORKSPACE",
|
||||
"sort.cancelled": "Сортировка {{item}} отменена.",
|
||||
"sort.finished": "{{item}} размещён на позиции {{position}} из {{count}}.",
|
||||
"sort.handle": "Изменить порядок: {{item}}",
|
||||
"sort.instructions": "Нажмите Пробел или Enter, чтобы начать сортировку. Перемещайте стрелками вверх и вниз. Нажмите Пробел или Enter для подтверждения, Escape для отмены.",
|
||||
"sort.item": "Элемент {{position}}",
|
||||
"sort.moved": "{{item}} перемещён на позицию {{position}} из {{count}}.",
|
||||
"sort.started": "Выбран {{item}}, позиция {{position}} из {{count}}.",
|
||||
"stepByStepTour.completion.description": "Вы познакомились с основами. Пора что-нибудь создать.",
|
||||
"stepByStepTour.completion.dismiss": "Закрыть",
|
||||
"stepByStepTour.completion.label": "Step-by-step Tour завершен",
|
||||
|
||||
@@ -569,6 +569,13 @@
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workspace": "WORKSPACE",
|
||||
"sort.cancelled": "Razvrščanje {{item}} preklicano.",
|
||||
"sort.finished": "{{item}} postavljen na položaj {{position}} od {{count}}.",
|
||||
"sort.handle": "Prerazporedi {{item}}",
|
||||
"sort.instructions": "Pritisnite preslednico ali Enter za začetek razvrščanja. Premikajte s puščicama gor in dol. Potrdite s preslednico ali Enter oziroma prekličite z Escape.",
|
||||
"sort.item": "Element {{position}}",
|
||||
"sort.moved": "{{item}} premaknjen na položaj {{position}} od {{count}}.",
|
||||
"sort.started": "{{item}} izbran, položaj {{position}} od {{count}}.",
|
||||
"stepByStepTour.completion.description": "Ogledali ste si osnove. Čas je, da nekaj ustvarite.",
|
||||
"stepByStepTour.completion.dismiss": "Zapri",
|
||||
"stepByStepTour.completion.label": "Step-by-step Tour dokončan",
|
||||
|
||||
@@ -569,6 +569,13 @@
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workspace": "WORKSPACE",
|
||||
"sort.cancelled": "ยกเลิกการเรียงลำดับ {{item}} แล้ว",
|
||||
"sort.finished": "วาง {{item}} ที่ตำแหน่ง {{position}} จาก {{count}} แล้ว",
|
||||
"sort.handle": "เรียงลำดับ {{item}} ใหม่",
|
||||
"sort.instructions": "กด Space หรือ Enter เพื่อเริ่มเรียงลำดับ ใช้ลูกศรขึ้นและลงเพื่อย้าย กด Space หรือ Enter เพื่อยืนยัน หรือ Escape เพื่อยกเลิก",
|
||||
"sort.item": "รายการที่ {{position}}",
|
||||
"sort.moved": "ย้าย {{item}} ไปยังตำแหน่ง {{position}} จาก {{count}} แล้ว",
|
||||
"sort.started": "เลือกรายการ {{item}} แล้ว อยู่ที่ตำแหน่ง {{position}} จาก {{count}}",
|
||||
"stepByStepTour.completion.description": "คุณได้เห็นสิ่งสำคัญแล้ว ถึงเวลาสร้างบางอย่าง",
|
||||
"stepByStepTour.completion.dismiss": "ปิด",
|
||||
"stepByStepTour.completion.label": "Step-by-step Tour เสร็จสมบูรณ์",
|
||||
|
||||
@@ -569,6 +569,13 @@
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workspace": "WORKSPACE",
|
||||
"sort.cancelled": "{{item}} sıralaması iptal edildi.",
|
||||
"sort.finished": "{{item}}, {{count}} öğeden {{position}}. sıraya yerleştirildi.",
|
||||
"sort.handle": "{{item}} sırasını değiştir",
|
||||
"sort.instructions": "Sıralamayı başlatmak için Boşluk veya Enter tuşuna basın. Yukarı ve aşağı oklarla taşıyın. Boşluk veya Enter ile onaylayın, Escape ile iptal edin.",
|
||||
"sort.item": "Öğe {{position}}",
|
||||
"sort.moved": "{{item}}, {{count}} öğeden {{position}}. sıraya taşındı.",
|
||||
"sort.started": "{{item}} seçildi, {{count}} öğeden {{position}}. sırada.",
|
||||
"stepByStepTour.completion.description": "Temel noktaları gördünüz. Artık bir şeyler oluşturma zamanı.",
|
||||
"stepByStepTour.completion.dismiss": "Kapat",
|
||||
"stepByStepTour.completion.label": "Step-by-step Tour tamamlandı",
|
||||
|
||||
@@ -569,6 +569,13 @@
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workspace": "WORKSPACE",
|
||||
"sort.cancelled": "Сортування {{item}} скасовано.",
|
||||
"sort.finished": "{{item}} розміщено на позиції {{position}} з {{count}}.",
|
||||
"sort.handle": "Змінити порядок: {{item}}",
|
||||
"sort.instructions": "Натисніть Пробіл або Enter, щоб почати сортування. Переміщуйте стрілками вгору та вниз. Натисніть Пробіл або Enter для підтвердження, Escape для скасування.",
|
||||
"sort.item": "Елемент {{position}}",
|
||||
"sort.moved": "{{item}} переміщено на позицію {{position}} з {{count}}.",
|
||||
"sort.started": "Вибрано {{item}}, позиція {{position}} з {{count}}.",
|
||||
"stepByStepTour.completion.description": "Ви переглянули основне. Час створити щось своє.",
|
||||
"stepByStepTour.completion.dismiss": "Закрити",
|
||||
"stepByStepTour.completion.label": "Step-by-step Tour завершено",
|
||||
|
||||
@@ -569,6 +569,13 @@
|
||||
"settings.swaggerAPIAsTool": "Swagger API as Tool",
|
||||
"settings.trigger": "Trigger",
|
||||
"settings.workspace": "WORKSPACE",
|
||||
"sort.cancelled": "Đã hủy sắp xếp {{item}}.",
|
||||
"sort.finished": "Đã đặt {{item}} ở vị trí {{position}} trên {{count}}.",
|
||||
"sort.handle": "Sắp xếp lại {{item}}",
|
||||
"sort.instructions": "Nhấn Phím cách hoặc Enter để bắt đầu sắp xếp. Dùng mũi tên lên và xuống để di chuyển. Nhấn Phím cách hoặc Enter để xác nhận, hoặc Escape để hủy.",
|
||||
"sort.item": "Mục {{position}}",
|
||||
"sort.moved": "Đã chuyển {{item}} đến vị trí {{position}} trên {{count}}.",
|
||||
"sort.started": "Đã chọn {{item}}, vị trí {{position}} trên {{count}}.",
|
||||
"stepByStepTour.completion.description": "Bạn đã xem những phần cốt lõi. Đến lúc bắt đầu xây dựng.",
|
||||
"stepByStepTour.completion.dismiss": "Đóng",
|
||||
"stepByStepTour.completion.label": "Step-by-step Tour đã hoàn tất",
|
||||
|
||||
@@ -569,6 +569,13 @@
|
||||
"settings.swaggerAPIAsTool": "Swagger API 作为工具",
|
||||
"settings.trigger": "触发器",
|
||||
"settings.workspace": "工作空间",
|
||||
"sort.cancelled": "已取消对 {{item}} 的排序。",
|
||||
"sort.finished": "已确认 {{item}} 位于第 {{position}} 项,共 {{count}} 项。",
|
||||
"sort.handle": "调整 {{item}} 的顺序",
|
||||
"sort.instructions": "按空格或 Enter 开始排序,用上下方向键移动。再次按空格或 Enter 确认,按 Esc 取消。",
|
||||
"sort.item": "第 {{position}} 项",
|
||||
"sort.moved": "已将 {{item}} 移至第 {{position}} 项,共 {{count}} 项。",
|
||||
"sort.started": "已选取 {{item}},当前为第 {{position}} 项,共 {{count}} 项。",
|
||||
"stepByStepTour.completion.description": "你已经了解了核心内容。现在可以开始构建了。",
|
||||
"stepByStepTour.completion.dismiss": "关闭",
|
||||
"stepByStepTour.completion.label": "Step-by-step Tour 已完成",
|
||||
|
||||
@@ -569,6 +569,13 @@
|
||||
"settings.swaggerAPIAsTool": "Swagger API 作為工具",
|
||||
"settings.trigger": "觸發器",
|
||||
"settings.workspace": "工作區",
|
||||
"sort.cancelled": "已取消對 {{item}} 的排序。",
|
||||
"sort.finished": "已確認 {{item}} 位於第 {{position}} 項,共 {{count}} 項。",
|
||||
"sort.handle": "調整 {{item}} 的順序",
|
||||
"sort.instructions": "按空白鍵或 Enter 開始排序,使用上下方向鍵移動。再次按空白鍵或 Enter 確認,按 Esc 取消。",
|
||||
"sort.item": "第 {{position}} 項",
|
||||
"sort.moved": "已將 {{item}} 移至第 {{position}} 項,共 {{count}} 項。",
|
||||
"sort.started": "已選取 {{item}},目前為第 {{position}} 項,共 {{count}} 項。",
|
||||
"stepByStepTour.completion.description": "你已經了解了核心內容。現在可以開始建構了。",
|
||||
"stepByStepTour.completion.dismiss": "關閉",
|
||||
"stepByStepTour.completion.label": "Step-by-step Tour 已完成",
|
||||
|
||||
Reference in new issue
Block a user