refactor(web): migrate workflow icon commands (#40649)

This commit is contained in:
yyh 2026-08-13 06:44:03 +00:00 committed by GitHub
parent d6f32a0267
commit 146f402a8c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 276 additions and 256 deletions

View File

@ -3488,13 +3488,13 @@
},
"web/app/components/workflow/comment/mention-input.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 3
"count": 1
},
"jsx_a11y/no-autofocus": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 3
"count": 1
}
},
"web/app/components/workflow/context.tsx": {
@ -4242,11 +4242,6 @@
"count": 1
}
},
"web/app/components/workflow/nodes/human-input/components/user-action.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/workflow/nodes/human-input/types.ts": {
"erasable-syntax-only/enums": {
"count": 2

View File

@ -8,8 +8,8 @@ import type {
ToolWithProvider,
} from '../types'
import type { TabType } 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 {
Popover,
PopoverClose,
@ -130,20 +130,20 @@ function BlockSelector({
/>
) : (
<PopoverTrigger
aria-label={t(($) => $['common.addBlock'], { ns: 'workflow' })}
disabled={disabled}
render={
<Button
<IconButton
aria-label={t(($) => $['common.addBlock'], { ns: 'workflow' })}
variant="primary"
size="small"
className={cn('z-10 size-4 rounded-full p-0', triggerClassName)}
size="xs"
className={cn('z-10 rounded-full', triggerClassName)}
style={triggerStyle}
/>
>
<span aria-hidden className="i-custom-vender-line-general-plus-02 size-2.5" />
</IconButton>
}
onClick={handleTrigger}
>
<span aria-hidden className="i-custom-vender-line-general-plus-02 size-2.5" />
</PopoverTrigger>
/>
)
const triggerWithTooltip = triggerTooltip ? (
<TipPopup title={triggerTooltip}>{triggerControl}</TipPopup>

View File

@ -1,5 +1,4 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import {
DropdownMenu,
DropdownMenuContent,
@ -7,6 +6,7 @@ import {
DropdownMenuLinkItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { useMutation } from '@tanstack/react-query'
import { useTheme } from 'next-themes'
import { memo } from 'react'
@ -52,17 +52,17 @@ function OperationDropdown({ open, onOpenChange, author, name, version }: Props)
<DropdownMenu open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger
render={
<Button
<IconButton
variant="ghost"
size="small"
className="size-6 min-h-0 rounded-lg p-0 focus-visible:ring-inset data-popup-open:bg-state-base-hover"
size="md"
className="rounded-lg focus-visible:ring-inset data-popup-open:bg-state-base-hover"
aria-label={t(($) => $['operation.more'], { ns: 'common' })}
>
<span
aria-hidden
className="i-ri-more-fill size-4 text-components-button-secondary-accent-text"
/>
</Button>
</IconButton>
}
/>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-[176px]">

View File

@ -1,7 +1,7 @@
import type { OnNodeAdd } from '../../types'
import type { SnippetListItem as SnippetListItemData } from '@/types/snippet'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import {
createPreviewCardHandle,
PreviewCard,
@ -131,15 +131,15 @@ const Snippets = ({ searchText, onSearchTextChange, insertPayload, onInserted }:
onChange={(event) => onSearchTextChange?.(event.target.value)}
/>
{!!searchText && (
<Button
<IconButton
variant="ghost"
size="small"
size="md"
aria-label={t(($) => $['tabs.clearSnippetSearch'], { ns: 'workflow' })}
className="size-6 min-h-0 shrink-0 p-0 focus-visible:ring-inset"
className="shrink-0 focus-visible:ring-inset"
onClick={() => onSearchTextChange?.('')}
>
<span className="i-ri-close-line size-4 text-text-tertiary" aria-hidden="true" />
</Button>
</IconButton>
)}
</div>
<div className="mx-0 mr-0.5 h-3.5 w-px bg-divider-regular" />

View File

@ -5,7 +5,7 @@ import type { UserProfile } from '@/app/components/workflow/comment/types'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowUpLine, RiAtLine, RiLoader2Line } from '@remixicon/react'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import {
forwardRef,
memo,
@ -568,29 +568,35 @@ const MentionInputInner = forwardRef<HTMLTextAreaElement, MentionInputProps>(
ref={setActionContainerRef}
className="absolute right-1 bottom-0 z-20 flex items-end gap-1"
>
<div
className={cn(
'z-20 flex size-8 items-center justify-center rounded-lg transition-opacity',
shouldDisableMentionButton
? 'cursor-not-allowed opacity-40'
: 'cursor-pointer hover:bg-state-base-hover',
)}
onClick={shouldDisableMentionButton ? undefined : handleMentionButtonClick}
<IconButton
aria-label={t(($) => $['nodes.agent.task.mention'], { ns: 'workflow' })}
size="lg"
disabled={shouldDisableMentionButton}
className="z-20 transition-opacity data-disabled:opacity-40"
onClick={handleMentionButtonClick}
>
<RiAtLine className="size-4 text-text-tertiary" />
</div>
<Button
className="z-20 ml-2 w-8 px-0"
<span aria-hidden className="i-ri-at-line size-4 text-text-tertiary" />
</IconButton>
<IconButton
aria-label={t(($) => $['operation.send'], { ns: 'common' })}
className="z-20 ml-2"
size="lg"
variant="primary"
disabled={!value.trim() || disabled || loading}
onClick={handleSubmit}
>
{loading ? (
<RiLoader2Line className="size-4 animate-spin text-components-button-primary-text" />
<span
aria-hidden
className="i-ri-loader-2-line size-4 animate-spin text-components-button-primary-text"
/>
) : (
<RiArrowUpLine className="size-4 text-components-button-primary-text" />
<span
aria-hidden
className="i-ri-arrow-up-line size-4 text-components-button-primary-text"
/>
)}
</Button>
</IconButton>
</div>
)}
@ -599,17 +605,15 @@ const MentionInputInner = forwardRef<HTMLTextAreaElement, MentionInputProps>(
ref={setActionContainerRef}
className="absolute inset-x-1 bottom-0 z-20 flex items-end justify-between"
>
<div
className={cn(
'z-20 flex size-8 items-center justify-center rounded-lg transition-opacity',
shouldDisableMentionButton
? 'cursor-not-allowed opacity-40'
: 'cursor-pointer hover:bg-state-base-hover',
)}
onClick={shouldDisableMentionButton ? undefined : handleMentionButtonClick}
<IconButton
aria-label={t(($) => $['nodes.agent.task.mention'], { ns: 'workflow' })}
size="lg"
disabled={shouldDisableMentionButton}
className="z-20 transition-opacity data-disabled:opacity-40"
onClick={handleMentionButtonClick}
>
<RiAtLine className="size-4 text-text-tertiary" />
</div>
<span aria-hidden className="i-ri-at-line size-4 text-text-tertiary" />
</IconButton>
<div ref={setActionRightRef} className="flex items-center gap-2">
<Button variant="secondary" size="small" onClick={onCancel} disabled={loading}>
{t(($) => $['operation.cancel'], { ns: 'common' })}
@ -620,7 +624,9 @@ const MentionInputInner = forwardRef<HTMLTextAreaElement, MentionInputProps>(
disabled={loading || !value.trim()}
onClick={() => handleSubmit()}
>
{loading && <RiLoader2Line className="size-3.5 animate-spin" />}
{loading && (
<span aria-hidden className="i-ri-loader-2-line size-3.5 animate-spin" />
)}
<span>{t(($) => $['operation.save'], { ns: 'common' })}</span>
{!loading && <EnterKey className="size-4" />}
</Button>

View File

@ -1,12 +1,13 @@
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { memo } from 'react'
import { BubbleX } from '@/app/components/base/icons/src/vender/line/others'
import { useTranslation } from 'react-i18next'
import { useStore } from '@/app/components/workflow/store'
import useTheme from '@/hooks/use-theme'
const ChatVariableButton = ({ disabled }: { disabled: boolean }) => {
const { theme } = useTheme()
const { t } = useTranslation()
const showChatVariablePanel = useStore((s) => s.showChatVariablePanel)
const setShowChatVariablePanel = useStore((s) => s.setShowChatVariablePanel)
const setShowEnvPanel = useStore((s) => s.setShowEnvPanel)
@ -21,17 +22,23 @@ const ChatVariableButton = ({ disabled }: { disabled: boolean }) => {
}
return (
<Button
<IconButton
aria-label={t(($) => $['chatVariable.panelTitle'], { ns: 'workflow' })}
aria-expanded={showChatVariablePanel}
size="lg"
className={cn(
'rounded-lg border border-transparent p-2',
'border border-transparent',
theme === 'dark' && showChatVariablePanel && 'border-black/5 bg-white/10 backdrop-blur-xs',
)}
disabled={disabled}
onClick={handleClick}
variant="ghost"
>
<BubbleX className="size-4 text-components-button-secondary-text" />
</Button>
<span
aria-hidden
className="i-custom-vender-line-others-bubble-x size-4 text-components-button-secondary-text"
/>
</IconButton>
)
}

View File

@ -1,13 +1,14 @@
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { memo } from 'react'
import { Env } from '@/app/components/base/icons/src/vender/line/others'
import { useTranslation } from 'react-i18next'
import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks/use-input-field-panel'
import { useStore } from '@/app/components/workflow/store'
import useTheme from '@/hooks/use-theme'
const EnvButton = ({ disabled }: { disabled: boolean }) => {
const { theme } = useTheme()
const { t } = useTranslation()
const setShowChatVariablePanel = useStore((s) => s.setShowChatVariablePanel)
const showEnvPanel = useStore((s) => s.showEnvPanel)
const setShowEnvPanel = useStore((s) => s.setShowEnvPanel)
@ -24,17 +25,23 @@ const EnvButton = ({ disabled }: { disabled: boolean }) => {
}
return (
<Button
<IconButton
aria-label={t(($) => $['env.envPanelTitle'], { ns: 'workflow' })}
aria-expanded={showEnvPanel}
size="lg"
className={cn(
'rounded-lg border border-transparent p-2',
'border border-transparent',
theme === 'dark' && showEnvPanel && 'border-black/5 bg-white/10 backdrop-blur-xs',
)}
variant="ghost"
disabled={disabled}
onClick={handleClick}
>
<Env className="size-4 text-components-button-secondary-text" />
</Button>
<span
aria-hidden
className="i-custom-vender-line-others-env size-4 text-components-button-secondary-text"
/>
</IconButton>
)
}

View File

@ -1,13 +1,14 @@
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { memo } from 'react'
import { GlobalVariable } from '@/app/components/base/icons/src/vender/line/others'
import { useTranslation } from 'react-i18next'
import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks/use-input-field-panel'
import { useStore } from '@/app/components/workflow/store'
import useTheme from '@/hooks/use-theme'
const GlobalVariableButton = ({ disabled }: { disabled: boolean }) => {
const { theme } = useTheme()
const { t } = useTranslation()
const showGlobalVariablePanel = useStore((s) => s.showGlobalVariablePanel)
const setShowGlobalVariablePanel = useStore((s) => s.setShowGlobalVariablePanel)
const setShowEnvPanel = useStore((s) => s.setShowEnvPanel)
@ -24,9 +25,12 @@ const GlobalVariableButton = ({ disabled }: { disabled: boolean }) => {
}
return (
<Button
<IconButton
aria-label={t(($) => $['globalVar.title'], { ns: 'workflow' })}
aria-expanded={showGlobalVariablePanel}
size="lg"
className={cn(
'rounded-lg border border-transparent p-2',
'border border-transparent',
theme === 'dark' &&
showGlobalVariablePanel &&
'border-black/5 bg-white/10 backdrop-blur-xs',
@ -35,8 +39,11 @@ const GlobalVariableButton = ({ disabled }: { disabled: boolean }) => {
onClick={handleClick}
variant="ghost"
>
<GlobalVariable className="size-4 text-components-button-secondary-text" />
</Button>
<span
aria-hidden
className="i-custom-vender-line-others-global-variable size-4 text-components-button-secondary-text"
/>
</IconButton>
)
}

View File

@ -1,5 +1,4 @@
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { memo, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ViewWorkflowHistory from '@/app/components/workflow/header/view-workflow-history'
@ -29,38 +28,30 @@ function UndoRedo({ handleUndo, handleRedo }: UndoRedoProps) {
return (
<div className="flex items-center space-x-0.5 rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg backdrop-blur-[5px]">
<TipPopup title={t(($) => $['common.undo'], { ns: 'workflow' })!} shortcut="workflow.undo">
<Button
variant="ghost"
size="small"
<IconButton
size="lg"
className="rounded-md"
aria-label={t(($) => $['common.undo'], { ns: 'workflow' })!}
data-tooltip-id="workflow.undo"
disabled={nodesReadOnly || buttonsDisabled.undo}
focusableWhenDisabled
className={cn(
'size-8 p-0 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
'data-disabled:cursor-not-allowed data-disabled:text-text-disabled data-disabled:hover:bg-transparent data-disabled:hover:text-text-disabled',
)}
onClick={handleUndo}
>
<span aria-hidden className="i-ri-arrow-go-back-line size-4" />
</Button>
</IconButton>
</TipPopup>
<TipPopup title={t(($) => $['common.redo'], { ns: 'workflow' })!} shortcut="workflow.redo">
<Button
variant="ghost"
size="small"
<IconButton
size="lg"
className="rounded-md"
aria-label={t(($) => $['common.redo'], { ns: 'workflow' })!}
data-tooltip-id="workflow.redo"
disabled={nodesReadOnly || buttonsDisabled.redo}
focusableWhenDisabled
className={cn(
'size-8 p-0 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
'data-disabled:cursor-not-allowed data-disabled:text-text-disabled data-disabled:hover:bg-transparent data-disabled:hover:text-text-disabled',
)}
onClick={handleRedo}
>
<span aria-hidden className="i-ri-arrow-go-forward-fill size-4" />
</Button>
</IconButton>
</TipPopup>
<Divider type="vertical" className="mx-0.5 h-3.5" />
<ViewWorkflowHistory />

View File

@ -1,5 +1,5 @@
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useHotkey } from '@tanstack/react-hotkeys'
import { useTranslation } from 'react-i18next'
@ -42,10 +42,12 @@ export function VersionHistoryButton({ onClick }: VersionHistoryButtonProps) {
<Tooltip>
<TooltipTrigger
render={
<Button
<IconButton
aria-label={label}
variant="secondary"
size="lg"
className={cn(
'rounded-lg p-2 inset-ring-1 inset-ring-transparent',
'inset-ring-1 inset-ring-transparent',
theme === 'dark' && 'bg-white/10 inset-ring-black/5 backdrop-blur-xs',
)}
onClick={onClick}
@ -54,7 +56,7 @@ export function VersionHistoryButton({ onClick }: VersionHistoryButtonProps) {
aria-hidden
className="i-ri-history-line size-4 text-components-button-secondary-text"
/>
</Button>
</IconButton>
}
/>
<TooltipContent className="rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]">

View File

@ -1,6 +1,6 @@
import type { WorkflowHistoryState } from '../store/workflow/history-slice'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -156,26 +156,22 @@ const ViewWorkflowHistory = () => {
)
}
render={
<Button
variant="ghost"
size="small"
<IconButton
size="lg"
disabled={nodesReadOnly}
focusableWhenDisabled
aria-label={t(($) => $['changeHistory.title'], { ns: 'workflow' })}
className={cn(
'size-8 p-0 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
'data-disabled:cursor-not-allowed data-disabled:text-text-disabled data-disabled:hover:bg-transparent data-disabled:hover:text-text-disabled',
)}
className="rounded-md"
onClick={() => {
if (nodesReadOnly) return
setCurrentLogItem()
setShowMessageLogModal(false)
}}
/>
>
<span aria-hidden className="i-ri-history-line size-4 shrink-0" />
</IconButton>
}
>
<span aria-hidden className="i-ri-history-line size-4 shrink-0" />
</PopoverTrigger>
/>
</TipPopup>
<PopoverContent
placement="bottom-end"
@ -188,14 +184,14 @@ const ViewWorkflowHistory = () => {
</div>
<PopoverClose
render={
<Button
<IconButton
variant="ghost"
size="small"
size="md"
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
className="size-6 shrink-0 p-0 text-text-secondary hover:bg-state-base-hover"
className="shrink-0 text-text-secondary hover:bg-state-base-hover"
>
<span aria-hidden className="i-ri-close-line size-4 text-text-secondary" />
</Button>
</IconButton>
}
onClick={() => {
setCurrentLogItem()

View File

@ -0,0 +1,20 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ToggleExpandBtn from '../toggle-expand-btn'
describe('ToggleExpandBtn', () => {
it('exposes the localized next action and toggles expansion', async () => {
const user = userEvent.setup()
const onExpandChange = vi.fn()
const { rerender } = render(
<ToggleExpandBtn isExpand={false} onExpandChange={onExpandChange} />,
)
await user.click(screen.getByRole('button', { name: 'share.chat.expand' }))
expect(onExpandChange).toHaveBeenCalledWith(true)
rerender(<ToggleExpandBtn isExpand onExpandChange={onExpandChange} />)
await user.click(screen.getByRole('button', { name: 'share.chat.collapse' }))
expect(onExpandChange).toHaveBeenLastCalledWith(false)
})
})

View File

@ -5,6 +5,7 @@ import {
DropdownMenuContent,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { intersection } from 'es-toolkit/array'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
@ -71,12 +72,14 @@ const Operator = ({ open, onOpenChange, data, nodeId, sourceHandle }: OperatorPr
<DropdownMenu open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger
render={
<Button
className="size-6 p-0"
<IconButton
variant="secondary"
size="md"
className="rounded-lg"
aria-label={t(($) => $['common.moreActions'], { ns: 'workflow' })}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</Button>
</IconButton>
}
/>
<DropdownMenuContent

View File

@ -4,6 +4,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
type Props = Readonly<{
isExpand: boolean
@ -11,12 +12,16 @@ type Props = Readonly<{
}>
const ExpandBtn: FC<Props> = ({ isExpand, onExpandChange }) => {
const { t } = useTranslation()
const handleToggle = useCallback(() => {
onExpandChange(!isExpand)
}, [isExpand, onExpandChange])
return (
<IconButton aria-label={isExpand ? 'Collapse' : 'Expand'} onClick={handleToggle}>
<IconButton
aria-label={t(($) => $[isExpand ? 'chat.collapse' : 'chat.expand'], { ns: 'share' })}
onClick={handleToggle}
>
<span
aria-hidden="true"
className={cn(

View File

@ -10,9 +10,8 @@ vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/base/input', () => ({
__esModule: true,
default: (props: {
vi.mock('@langgenius/dify-ui/input', () => ({
Input: (props: {
value: string
placeholder?: string
disabled?: boolean

View File

@ -1,11 +1,10 @@
import type { FC } from 'react'
import type { UserAction } from '../types'
import { Button } from '@langgenius/dify-ui/button'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { Input } from '@langgenius/dify-ui/input'
import { toast } from '@langgenius/dify-ui/toast'
import { RiDeleteBinLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import ButtonStyleDropdown from './button-style-dropdown'
const i18nPrefix = 'nodes.humanInput'
@ -75,7 +74,7 @@ const UserActionItem: FC<UserActionItemProps> = ({ data, onChange, onDelete, rea
<div className="flex items-center gap-1">
<div className="shrink-0">
<Input
wrapperClassName="w-[120px]"
className="w-[120px]"
value={data.id}
placeholder={t(($) => $[`${i18nPrefix}.userActions.actionNamePlaceholder`], {
ns: 'workflow',
@ -101,14 +100,14 @@ const UserActionItem: FC<UserActionItemProps> = ({ data, onChange, onDelete, rea
readonly={readonly}
/>
{!readonly && (
<Button
<IconButton
aria-label={t(($) => $['operation.delete'], { ns: 'common' })}
className="px-2"
size="lg"
variant="tertiary"
onClick={() => onDelete(data.id)}
>
<RiDeleteBinLine className="size-4" />
</Button>
<span aria-hidden className="i-ri-delete-bin-line size-4" />
</IconButton>
)}
</div>
)

View File

@ -1,5 +1,5 @@
import type { NodeDefault } from '../../types'
import { screen } from '@testing-library/react'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { FlowType } from '@/types/common'
import { createNode } from '../../__tests__/fixtures'
@ -128,17 +128,20 @@ describe('AddBlock', () => {
mockFlowType = FlowType.appFlow
})
it('opens the real selector and derives trigger styling from its open state', async () => {
it('opens the real selector and exposes its open state', async () => {
const user = userEvent.setup()
renderWithReactFlow()
const trigger = screen.getByRole('button', { name: 'workflow.common.addBlock' })
expect(trigger).not.toHaveAttribute('data-popup-open')
expect(trigger).not.toHaveAttribute('data-block-selector-open')
await user.hover(trigger)
await waitFor(() => expect(trigger).toHaveAttribute('data-popup-open', ''))
expect(trigger).not.toHaveAttribute('data-block-selector-open')
await user.click(trigger)
expect(trigger).toHaveAttribute('data-popup-open')
expect(trigger).toHaveClass('bg-state-accent-active')
expect(trigger).toHaveAttribute('data-block-selector-open', '')
expect(screen.getByRole('dialog', { name: 'workflow.common.addBlock' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Answer' })).toBeInTheDocument()
})

View File

@ -85,18 +85,20 @@ describe('Control', () => {
}
})
// Rendering and visual states for control buttons.
// Rendering and semantic states for control buttons.
describe('Rendering', () => {
it('should render the child action groups and highlight the active pointer mode', () => {
render(<Control />)
expect(screen.getByTestId('add-block')).toBeInTheDocument()
expect(screen.getByTestId('more-actions')).toBeInTheDocument()
expect(screen.getByTestId('workflow.common.pointerMode').firstElementChild).toHaveClass(
'bg-state-accent-active',
expect(screen.getByRole('button', { name: 'workflow.common.pointerMode' })).toHaveAttribute(
'aria-pressed',
'true',
)
expect(screen.getByTestId('workflow.common.handMode').firstElementChild).not.toHaveClass(
'bg-state-accent-active',
expect(screen.getByRole('button', { name: 'workflow.common.handMode' })).toHaveAttribute(
'aria-pressed',
'false',
)
})
@ -107,8 +109,9 @@ describe('Control', () => {
render(<Control />)
expect(screen.getByTestId('workflow.common.handMode').firstElementChild).toHaveClass(
'bg-state-accent-active',
expect(screen.getByRole('button', { name: 'workflow.common.handMode' })).toHaveAttribute(
'aria-pressed',
'true',
)
})
})

View File

@ -1,8 +1,6 @@
import type { ReactElement } from 'react'
import type { BlockSelectorProps } from '@/app/components/workflow/block-selector'
import type { Node, OnSelectBlock } from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStoreApi } from 'reactflow'
@ -28,7 +26,6 @@ type AddBlockProps = {
onClose?: () => void
isolateKeyboardEvents?: boolean
}
type BlockSelectorTriggerRender = Exclude<NonNullable<BlockSelectorProps['trigger']>, ReactElement>
const AddBlock = ({
renderTrigger,
sideOffset,
@ -85,27 +82,17 @@ const AddBlock = ({
[store, workflowStore, nodesMetaDataMap],
)
const renderTriggerElement = useCallback<BlockSelectorTriggerRender>(
(props, state) => {
return (
<Button
{...props}
variant="ghost"
size="small"
disabled={nodesReadOnly}
focusableWhenDisabled
className={cn(
'size-8 p-0 text-text-tertiary hover:text-text-secondary',
'data-disabled:text-text-disabled data-disabled:hover:bg-transparent data-disabled:hover:text-text-disabled',
state.open && 'bg-state-accent-active text-text-accent',
props.className,
)}
>
<span aria-hidden className="i-ri-add-circle-fill size-4" />
</Button>
)
},
[nodesReadOnly],
const renderTriggerElement = (
<IconButton
aria-label={t(($) => $['common.addBlock'], { ns: 'workflow' })}
size="lg"
disabled={nodesReadOnly}
focusableWhenDisabled
data-block-selector-open={open ? '' : undefined}
className="rounded-md data-block-selector-open:bg-state-accent-active data-block-selector-open:text-text-accent"
>
<span aria-hidden className="i-ri-add-circle-fill size-4" />
</IconButton>
)
return (

View File

@ -1,6 +1,6 @@
import type { MouseEvent } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { Toggle } from '@langgenius/dify-ui/toggle'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '../../base/divider'
@ -14,6 +14,9 @@ import { useOperator } from './hooks'
import MoreActions from './more-actions'
import TipPopup from './tip-popup'
const pressedModeClassName =
'data-pressed:bg-state-accent-active data-pressed:text-text-accent data-pressed:hover:bg-state-base-hover data-pressed:hover:text-text-secondary data-disabled:data-pressed:text-text-disabled data-disabled:data-pressed:hover:bg-transparent data-disabled:data-pressed:hover:text-text-disabled'
const Control = () => {
const { t } = useTranslation()
const controlMode = useStore((s) => s.controlMode)
@ -39,88 +42,84 @@ const Control = () => {
<div className="pointer-events-auto flex flex-col items-center rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 text-text-tertiary shadow-lg">
<AddBlock />
<TipPopup title={t(($) => $['nodes.note.addNote'], { ns: 'workflow' })}>
<Button
variant="ghost"
size="small"
<IconButton
size="lg"
aria-label={t(($) => $['nodes.note.addNote'], { ns: 'workflow' })}
disabled={nodesReadOnly}
focusableWhenDisabled
className={cn(
'ml-px size-8 p-0 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
'data-disabled:cursor-not-allowed data-disabled:text-text-disabled data-disabled:hover:bg-transparent data-disabled:hover:text-text-disabled',
)}
className="ml-px rounded-md"
onClick={addNote}
>
<span aria-hidden className="i-ri-sticky-note-add-line size-4" />
</Button>
</IconButton>
</TipPopup>
<Divider className="my-1 w-3.5" />
<TipPopup
title={t(($) => $['common.pointerMode'], { ns: 'workflow' })}
shortcut="workflow.pointer-mode"
>
<Button
variant="ghost"
size="small"
aria-label={t(($) => $['common.pointerMode'], { ns: 'workflow' })}
<Toggle
pressed={controlMode === ControlMode.Pointer}
onPressedChange={handleModePointer}
disabled={nodesReadOnly}
focusableWhenDisabled
className={cn(
'mr-px size-8 p-0 text-text-tertiary',
controlMode === ControlMode.Pointer
? 'bg-state-accent-active text-text-accent'
: 'hover:bg-state-base-hover hover:text-text-secondary',
'data-disabled:cursor-not-allowed data-disabled:text-text-disabled data-disabled:hover:bg-transparent data-disabled:hover:text-text-disabled',
)}
onClick={handleModePointer}
>
<span aria-hidden className="i-ri-cursor-line size-4" />
</Button>
className={pressedModeClassName}
render={
<IconButton
size="lg"
aria-label={t(($) => $['common.pointerMode'], { ns: 'workflow' })}
disabled={nodesReadOnly}
focusableWhenDisabled
className="mr-px rounded-md"
>
<span aria-hidden className="i-ri-cursor-line size-4" />
</IconButton>
}
/>
</TipPopup>
<TipPopup
title={t(($) => $['common.handMode'], { ns: 'workflow' })}
shortcut="workflow.hand-mode"
>
<Button
variant="ghost"
size="small"
aria-label={t(($) => $['common.handMode'], { ns: 'workflow' })}
<Toggle
pressed={controlMode === ControlMode.Hand}
onPressedChange={handleModeHand}
disabled={nodesReadOnly}
focusableWhenDisabled
className={cn(
'size-8 p-0 text-text-tertiary',
controlMode === ControlMode.Hand
? 'bg-state-accent-active text-text-accent'
: 'hover:bg-state-base-hover hover:text-text-secondary',
'data-disabled:cursor-not-allowed data-disabled:text-text-disabled data-disabled:hover:bg-transparent data-disabled:hover:text-text-disabled',
)}
onClick={handleModeHand}
>
<span aria-hidden className="i-ri-hand size-4" />
</Button>
className={pressedModeClassName}
render={
<IconButton
size="lg"
aria-label={t(($) => $['common.handMode'], { ns: 'workflow' })}
disabled={nodesReadOnly}
focusableWhenDisabled
className="rounded-md"
>
<span aria-hidden className="i-ri-hand size-4" />
</IconButton>
}
/>
</TipPopup>
{isCommentModeAvailable && (
<TipPopup
title={t(($) => $['common.commentMode'], { ns: 'workflow' })}
shortcut="workflow.comment-mode"
>
<Button
variant="ghost"
size="small"
aria-label={t(($) => $['common.commentMode'], { ns: 'workflow' })}
<Toggle
pressed={controlMode === ControlMode.Comment}
onPressedChange={handleModeComment}
disabled={!canUseCommentMode}
focusableWhenDisabled
className={cn(
'ml-px size-8 p-0 text-text-tertiary',
controlMode === ControlMode.Comment
? 'bg-state-accent-active text-text-accent'
: 'hover:bg-state-base-hover hover:text-text-secondary',
'data-disabled:cursor-not-allowed data-disabled:text-text-disabled data-disabled:hover:bg-transparent data-disabled:hover:text-text-disabled',
)}
onClick={handleModeComment}
>
<span aria-hidden className="i-custom-public-other-comment size-4" />
</Button>
className={pressedModeClassName}
render={
<IconButton
size="lg"
aria-label={t(($) => $['common.commentMode'], { ns: 'workflow' })}
disabled={!canUseCommentMode}
focusableWhenDisabled
className="ml-px rounded-md"
>
<span aria-hidden className="i-custom-public-other-comment size-4" />
</IconButton>
}
/>
</TipPopup>
)}
<Divider className="my-1 w-3.5" />
@ -128,20 +127,16 @@ const Control = () => {
title={t(($) => $['panel.organizeBlocks'], { ns: 'workflow' })}
shortcut="workflow.organize"
>
<Button
variant="ghost"
size="small"
<IconButton
size="lg"
aria-label={t(($) => $['panel.organizeBlocks'], { ns: 'workflow' })}
disabled={nodesReadOnly}
focusableWhenDisabled
className={cn(
'size-8 p-0 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
'data-disabled:cursor-not-allowed data-disabled:text-text-disabled data-disabled:hover:bg-transparent data-disabled:hover:text-text-disabled',
)}
className="rounded-md"
onClick={handleLayout}
>
<span aria-hidden className="i-ri-function-add-line size-4" />
</Button>
</IconButton>
</TipPopup>
<MoreActions />
</div>

View File

@ -1,5 +1,3 @@
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
@ -7,6 +5,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { toJpeg, toPng, toSvg } from 'html-to-image'
import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -158,21 +157,17 @@ function MoreActions() {
<TipPopup title={t(($) => $['common.moreActions'], { ns: 'workflow' })}>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="small"
<IconButton
size="lg"
aria-label={t(($) => $['common.moreActions'], { ns: 'workflow' })}
disabled={isReadOnly}
focusableWhenDisabled
className={cn(
'size-8 p-0 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
'data-disabled:cursor-not-allowed data-disabled:text-text-disabled data-disabled:hover:bg-transparent data-disabled:hover:text-text-disabled',
)}
/>
className="rounded-md"
>
<span aria-hidden className="i-ri-more-fill size-4" />
</IconButton>
}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
/>
</TipPopup>
<DropdownMenuContent placement="right-end" popupClassName="min-w-[180px]">
<div className="flex items-center gap-2 px-2 py-1 text-xs font-medium text-text-tertiary">

View File

@ -1,11 +1,11 @@
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { VersionHistoryContextMenuOptions } from '../../../types'
@ -31,16 +31,16 @@ const ActionMenu: FC<ActionMenuProps> = (props: ActionMenuProps) => {
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
render={
<Button
size="small"
className="px-1"
<IconButton
size="md"
variant="secondary"
aria-label={t(($) => $['operation.more'], { ns: 'common' })}
onClick={(e) => e.stopPropagation()}
/>
>
<span aria-hidden className="i-ri-more-fill size-4" />
</IconButton>
}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
/>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}

View File

@ -1,12 +1,11 @@
import type { AgentLogItemWithChildren } from '@/types/workflow'
import { Button } from '@langgenius/dify-ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { RiMoreLine } from '@remixicon/react'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -22,15 +21,16 @@ const AgentLogNavMore = ({ options, onShowAgentOrToolLog }: AgentLogNavMoreProps
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
render={
<Button
<IconButton
aria-label={t(($) => $['operation.more'], { ns: 'common' })}
className="size-6"
size="md"
variant="ghost-accent"
/>
className="rounded-lg"
>
<span aria-hidden className="i-ri-more-line size-4" />
</IconButton>
}
>
<RiMoreLine className="size-4" />
</DropdownMenuTrigger>
/>
<DropdownMenuContent
placement="bottom-start"
sideOffset={2}