mirror of
https://github.com/langgenius/dify.git
synced 2026-11-13 23:06:40 +08:00
fix(web): expose workflow image export in the toolbar (#43662)
This commit is contained in:
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',
|
||||
|
||||
+39
-28
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+57
-58
@@ -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 }
|
||||
Reference in new issue
Block a user