fix: make sortable lists keyboard accessible (#41974)

This commit is contained in:
Joel authored and GitHub committed 2026-09-09 07:01:54 +00:00
1 parent 603176c17d
commit b577502a9b
51 files changed
+1401 -290

No files matched your search

+1 -1
View File
@@ -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' })
@@ -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}`}>
@@ -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'
@@ -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>
</>
),
}
}
@@ -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' }),
])
})
})
@@ -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"
@@ -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>
</>
)
}
@@ -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}
@@ -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)
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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 کامل شد",
+7
View File
@@ -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é",
+7
View File
@@ -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 पूरा हुआ",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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 が完了しました",
+7
View File
@@ -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 완료",
+7
View File
@@ -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": "ການແນະນຳເທື່ອລະຂັ້ນຕອນສຳເລັດແລ້ວ",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -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 завершен",
+7
View File
@@ -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",
+7
View File
@@ -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 เสร็จสมบูรณ์",
+7
View File
@@ -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ı",
+7
View File
@@ -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 завершено",
+7
View File
@@ -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",
+7
View File
@@ -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 已完成",
+7
View File
@@ -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 已完成",