fix(web): expose workflow image export in the toolbar (#43662)

This commit is contained in:
yyh authored and GitHub committed 2026-10-08 06:53:58 +00:00
1 parent 1ed388d865
commit 0db9c8f6f4
4 files changed
+101 -91

No files matched your search

@@ -64,8 +64,8 @@ vi.mock('../add-block', () => ({
default: () => <div data-testid="add-block" />,
}))
vi.mock('../more-actions', () => ({
default: () => <div data-testid="more-actions" />,
vi.mock('../export-image', () => ({
ExportImage: () => <div data-testid="export-image" />,
}))
vi.mock('../tip-popup', () => ({
@@ -91,7 +91,7 @@ describe('Control', () => {
render(<Control />)
expect(screen.getByTestId('add-block')).toBeInTheDocument()
expect(screen.getByTestId('more-actions')).toBeInTheDocument()
expect(screen.getByTestId('export-image')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'workflow.common.pointerMode' })).toHaveAttribute(
'aria-pressed',
'true',
@@ -1,7 +1,7 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { act } from 'react'
import MoreActions from '../more-actions'
import { ExportImage } from '../export-image'
const mockToPng = vi.fn()
const mockToJpeg = vi.fn()
@@ -50,10 +50,6 @@ vi.mock('@/utils/download', () => ({
downloadUrl: (...args: unknown[]) => mockDownloadUrl(...args),
}))
vi.mock('../tip-popup', () => ({
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}))
vi.mock('@/app/components/base/image-uploader/image-preview', () => ({
default: ({ title, onCancel }: { title: string; onCancel: () => void }) => (
<div data-testid="image-preview">
@@ -65,7 +61,7 @@ vi.mock('@/app/components/base/image-uploader/image-preview', () => ({
),
}))
describe('MoreActions', () => {
describe('ExportImage', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
@@ -85,10 +81,24 @@ describe('MoreActions', () => {
it('opens the menu and exports the current view as png', async () => {
const user = userEvent.setup()
render(<MoreActions />)
render(<ExportImage />)
await user.click(screen.getByRole('button'))
await user.click(screen.getAllByText('workflow.common.exportPNG')[0]!)
await user.click(screen.getByRole('button', { name: 'workflow.common.exportImage' }))
const menu = screen.getByRole('menu', { name: 'workflow.common.exportImage' })
expect(within(menu).getAllByRole('menuitem')).toHaveLength(6)
expect(
within(menu).getByRole('group', { name: 'workflow.common.currentView' }),
).toBeInTheDocument()
expect(
within(menu).getByRole('group', { name: 'workflow.common.currentWorkflow' }),
).toBeInTheDocument()
expect(within(menu).queryByText('workflow.common.exportImage')).not.toBeInTheDocument()
await user.click(
within(within(menu).getByRole('group', { name: 'workflow.common.currentView' })).getByRole(
'menuitem',
{ name: 'workflow.common.exportPNG' },
),
)
await waitFor(() => {
expect(mockToPng).toHaveBeenCalledTimes(1)
@@ -97,30 +107,31 @@ describe('MoreActions', () => {
url: 'data:image/png;base64,current',
fileName: 'Demo App.png',
})
await waitFor(() => expect(screen.queryByRole('menu')).not.toBeInTheDocument())
})
it('does not open the menu when the workflow is read only', async () => {
const user = userEvent.setup()
mockGetNodesReadOnly.mockReturnValue(true)
render(<MoreActions />)
render(<ExportImage />)
const trigger = screen.getByRole('button', { name: 'workflow.common.moreActions' })
const trigger = screen.getByRole('button', { name: 'workflow.common.exportImage' })
expect(trigger).toHaveAttribute('aria-disabled', 'true')
await user.tab()
expect(trigger).toHaveFocus()
await user.keyboard('{Enter}')
expect(screen.queryByText('workflow.common.exportImage')).not.toBeInTheDocument()
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
it('shows a preview when exporting the whole workflow', async () => {
vi.useFakeTimers()
render(<MoreActions />)
render(<ExportImage />)
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.exportImage' }))
fireEvent.click(screen.getAllByText('workflow.common.exportPNG')[1]!)
await act(async () => {
await vi.advanceTimersByTimeAsync(300)
@@ -143,9 +154,9 @@ describe('MoreActions', () => {
])('exports the current view with %s', async (label, exporter, fileName) => {
const user = userEvent.setup()
render(<MoreActions />)
render(<ExportImage />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByRole('button', { name: 'workflow.common.exportImage' }))
await user.click(screen.getAllByText(label)[0]!)
await waitFor(() => {
@@ -160,9 +171,9 @@ describe('MoreActions', () => {
it('exports the whole workflow as svg', async () => {
vi.useFakeTimers()
render(<MoreActions />)
render(<ExportImage />)
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.exportImage' }))
fireEvent.click(screen.getAllByText('workflow.common.exportSVG')[1]!)
await act(async () => {
await vi.advanceTimersByTimeAsync(300)
@@ -181,9 +192,9 @@ describe('MoreActions', () => {
mockWorkflowState.appName = ''
mockWorkflowState.knowledgeName = ''
render(<MoreActions />)
render(<ExportImage />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByRole('button', { name: 'workflow.common.exportImage' }))
await user.click(screen.getAllByText('workflow.common.exportPNG')[0]!)
expect(mockToPng).not.toHaveBeenCalled()
@@ -194,9 +205,9 @@ describe('MoreActions', () => {
const user = userEvent.setup()
document.querySelector('.react-flow__viewport')?.remove()
render(<MoreActions />)
render(<ExportImage />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByRole('button', { name: 'workflow.common.exportImage' }))
await user.click(screen.getAllByText('workflow.common.exportPNG')[0]!)
expect(mockToPng).not.toHaveBeenCalled()
@@ -206,9 +217,9 @@ describe('MoreActions', () => {
it('returns early when the workflow becomes read only before exporting', async () => {
const user = userEvent.setup()
render(<MoreActions />)
render(<ExportImage />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByRole('button', { name: 'workflow.common.exportImage' }))
mockGetNodesReadOnly.mockReturnValue(true)
await user.click(screen.getAllByText('workflow.common.exportJPEG')[0]!)
@@ -221,9 +232,9 @@ describe('MoreActions', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockToJpeg.mockRejectedValueOnce(new Error('boom'))
render(<MoreActions />)
render(<ExportImage />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByRole('button', { name: 'workflow.common.exportImage' }))
await user.click(screen.getAllByText('workflow.common.exportJPEG')[0]!)
await waitFor(() => {
@@ -232,7 +243,7 @@ describe('MoreActions', () => {
expect(screen.queryByTestId('image-preview')).not.toBeInTheDocument()
mockToPng.mockResolvedValueOnce('data:image/png;base64,current')
fireEvent.click(screen.getByRole('button'))
fireEvent.click(screen.getByRole('button', { name: 'workflow.common.exportImage' }))
fireEvent.click(screen.getAllByText('workflow.common.exportPNG')[1]!)
await waitFor(() => {
expect(screen.getByTestId('image-preview')).toBeInTheDocument()
@@ -10,8 +10,8 @@ import { useWorkflowMoveMode } from '../hooks/use-workflow-panel-interactions'
import { useStore } from '../store'
import { ControlMode } from '../types'
import AddBlock from './add-block'
import { ExportImage } from './export-image'
import { useOperator } from './hooks'
import MoreActions from './more-actions'
import TipPopup from './tip-popup'
const pressedModeClassName =
@@ -138,7 +138,7 @@ const Control = () => {
<span aria-hidden className="i-ri-function-add-line size-4" />
</IconButton>
</TipPopup>
<MoreActions />
<ExportImage />
</div>
)
}
@@ -1,6 +1,8 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuGroupLabel,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
@@ -16,12 +18,11 @@ import { downloadUrl } from '@/utils/download'
import { useNodesReadOnly } from '../hooks/use-workflow'
import TipPopup from './tip-popup'
function MoreActions() {
const ExportImage = memo(() => {
const { t } = useTranslation(['workflow'])
const { getNodesReadOnly } = useNodesReadOnly()
const reactFlow = useReactFlow()
const [open, setOpen] = useState(false)
const [previewUrl, setPreviewUrl] = useState('')
const [previewTitle, setPreviewTitle] = useState('')
const knowledgeName = useStore((s) => s.knowledgeName)
@@ -34,7 +35,6 @@ function MoreActions() {
if (getNodesReadOnly()) return
setOpen(false)
const flowElement = document.querySelector('.react-flow__viewport') as HTMLElement
if (!flowElement) return
@@ -144,72 +144,71 @@ function MoreActions() {
return (
<>
<DropdownMenu
open={open}
onOpenChange={(nextOpen) => {
if (isReadOnly) {
setOpen(false)
return
}
setOpen(nextOpen)
}}
>
<TipPopup title={t(($) => $['common.moreActions'], { ns: 'workflow' })}>
<DropdownMenu disabled={isReadOnly}>
<TipPopup title={t(($) => $['common.exportImage'], { ns: 'workflow' })}>
{/* Tooltip also sets data-popup-open; Menu alone sets data-pressed for its open state. */}
<DropdownMenuTrigger
render={
<IconButton
size="lg"
aria-label={t(($) => $['common.moreActions'], { ns: 'workflow' })}
disabled={isReadOnly}
aria-label={t(($) => $['common.exportImage'], { ns: 'workflow' })}
focusableWhenDisabled
className="rounded-md"
className="rounded-md data-pressed:bg-state-base-hover data-pressed:text-text-secondary"
>
<span aria-hidden className="i-ri-more-fill size-4" />
<span aria-hidden className="i-ri-export-line size-4" />
</IconButton>
}
/>
</TipPopup>
<DropdownMenuContent placement="right-end" className="min-w-45">
<div className="flex items-center gap-2 px-2 py-1 text-xs font-medium text-text-tertiary">
<span aria-hidden className="i-ri-export-line size-3" />
{t(($) => $['common.exportImage'], { ns: 'workflow' })}
</div>
<div className="px-2 py-1 text-xs font-medium text-text-tertiary">
{t(($) => $['common.currentView'], { ns: 'workflow' })}
</div>
<DropdownMenuItem className="system-md-regular" onClick={() => handleExportImage('png')}>
{t(($) => $['common.exportPNG'], { ns: 'workflow' })}
</DropdownMenuItem>
<DropdownMenuItem className="system-md-regular" onClick={() => handleExportImage('jpeg')}>
{t(($) => $['common.exportJPEG'], { ns: 'workflow' })}
</DropdownMenuItem>
<DropdownMenuItem className="system-md-regular" onClick={() => handleExportImage('svg')}>
{t(($) => $['common.exportSVG'], { ns: 'workflow' })}
</DropdownMenuItem>
<DropdownMenuGroup>
<DropdownMenuGroupLabel>
{t(($) => $['common.currentView'], { ns: 'workflow' })}
</DropdownMenuGroupLabel>
<DropdownMenuItem
className="system-md-regular"
onClick={() => handleExportImage('png')}
>
{t(($) => $['common.exportPNG'], { ns: 'workflow' })}
</DropdownMenuItem>
<DropdownMenuItem
className="system-md-regular"
onClick={() => handleExportImage('jpeg')}
>
{t(($) => $['common.exportJPEG'], { ns: 'workflow' })}
</DropdownMenuItem>
<DropdownMenuItem
className="system-md-regular"
onClick={() => handleExportImage('svg')}
>
{t(($) => $['common.exportSVG'], { ns: 'workflow' })}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator className="mx-2" />
<div className="px-2 py-1 text-xs font-medium text-text-tertiary">
{t(($) => $['common.currentWorkflow'], { ns: 'workflow' })}
</div>
<DropdownMenuItem
className="system-md-regular"
onClick={() => handleExportImage('png', true)}
>
{t(($) => $['common.exportPNG'], { ns: 'workflow' })}
</DropdownMenuItem>
<DropdownMenuItem
className="system-md-regular"
onClick={() => handleExportImage('jpeg', true)}
>
{t(($) => $['common.exportJPEG'], { ns: 'workflow' })}
</DropdownMenuItem>
<DropdownMenuItem
className="system-md-regular"
onClick={() => handleExportImage('svg', true)}
>
{t(($) => $['common.exportSVG'], { ns: 'workflow' })}
</DropdownMenuItem>
<DropdownMenuGroup>
<DropdownMenuGroupLabel>
{t(($) => $['common.currentWorkflow'], { ns: 'workflow' })}
</DropdownMenuGroupLabel>
<DropdownMenuItem
className="system-md-regular"
onClick={() => handleExportImage('png', true)}
>
{t(($) => $['common.exportPNG'], { ns: 'workflow' })}
</DropdownMenuItem>
<DropdownMenuItem
className="system-md-regular"
onClick={() => handleExportImage('jpeg', true)}
>
{t(($) => $['common.exportJPEG'], { ns: 'workflow' })}
</DropdownMenuItem>
<DropdownMenuItem
className="system-md-regular"
onClick={() => handleExportImage('svg', true)}
>
{t(($) => $['common.exportSVG'], { ns: 'workflow' })}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
@@ -218,6 +217,6 @@ function MoreActions() {
)}
</>
)
}
})
export default memo(MoreActions)
export { ExportImage }