feat(web): show snippet publish action

This commit is contained in:
JzoNg 2026-06-22 15:28:15 +08:00
parent 01957f302f
commit b8cc01cf10
4 changed files with 64 additions and 400 deletions

View File

@ -7,35 +7,17 @@ import SnippetWorkflowPanel from './workflow-panel'
type SnippetChildrenProps = {
snippetId: string
fields: SnippetInputField[]
canDiscardChanges: boolean
canEdit?: boolean
canSave: boolean
hasDraftChanges: boolean
isEditing: boolean
isPublishing: boolean
onCancel: () => void
onEdit: () => void
onExitEditing: () => void | Promise<void>
onExitEditingWithoutSave: () => void | Promise<void>
onPublish: () => void
onSaveAndExitEditing: () => void | Promise<void>
}
const SnippetChildren = ({
snippetId,
fields,
canDiscardChanges,
canEdit = true,
canSave,
hasDraftChanges,
isEditing,
isPublishing,
onCancel,
onEdit,
onExitEditing,
onExitEditingWithoutSave,
onPublish,
onSaveAndExitEditing,
}: SnippetChildrenProps) => {
return (
<>
@ -43,18 +25,9 @@ const SnippetChildren = ({
<SnippetHeader
snippetId={snippetId}
canDiscardChanges={canDiscardChanges}
canEdit={canEdit}
canSave={canSave}
hasDraftChanges={hasDraftChanges}
isEditing={isEditing}
isPublishing={isPublishing}
onCancel={onCancel}
onEdit={onEdit}
onExitEditing={onExitEditing}
onExitEditingWithoutSave={onExitEditingWithoutSave}
onPublish={onPublish}
onSaveAndExitEditing={onSaveAndExitEditing}
/>
<SnippetWorkflowPanel

View File

@ -1,28 +1,8 @@
import type { ReactNode } from 'react'
import type { HeaderProps } from '@/app/components/workflow/header'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { expectLoadingButton } from '@/test/button'
import SnippetHeader from '..'
vi.mock('@langgenius/dify-ui/alert-dialog', () => ({
AlertDialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogActions: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogCancelButton: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
AlertDialogConfirmButton: ({
children,
disabled,
onClick,
}: {
children: ReactNode
disabled?: boolean
onClick?: () => void
}) => <button type="button" disabled={disabled} onClick={onClick}>{children}</button>,
AlertDialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogDescription: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogTrigger: ({ children, render }: { children?: ReactNode, render?: ReactNode }) => render ?? <button type="button">{children}</button>,
}))
vi.mock('@/app/components/workflow/header', () => ({
default: (props: HeaderProps) => {
return (
@ -44,242 +24,71 @@ vi.mock('@/app/components/workflow/header', () => ({
}))
describe('SnippetHeader', () => {
const mockCancel = vi.fn()
const mockEdit = vi.fn()
const mockExitEditing = vi.fn()
const mockExitEditingWithoutSave = vi.fn()
const mockPublish = vi.fn()
const mockSaveAndExit = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
// Verifies the wrapper passes the expected workflow header configuration.
describe('Rendering', () => {
it('should configure workflow header slots and hide workflow-only controls', () => {
render(
<SnippetHeader
snippetId="snippet-1"
canDiscardChanges
canSave
hasDraftChanges={false}
isEditing={false}
isPublishing={false}
onCancel={mockCancel}
onEdit={mockEdit}
onExitEditing={mockExitEditing}
onExitEditingWithoutSave={mockExitEditingWithoutSave}
onPublish={mockPublish}
onSaveAndExitEditing={mockSaveAndExit}
/>,
)
it('should configure workflow header slots and hide workflow-only controls', () => {
render(
<SnippetHeader
snippetId="snippet-1"
canSave
isPublishing={false}
onPublish={mockPublish}
/>,
)
const header = screen.getByTestId('workflow-header')
expect(header).toHaveAttribute('data-show-env', 'false')
expect(header).toHaveAttribute('data-show-global-variable', 'false')
expect(header).toHaveAttribute('data-history-url', '/snippets/snippet-1/workflow-runs')
expect(screen.getByText('snippet.viewOnly')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /snippet\.edit/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /snippet\.testRunButton/i })).toBeInTheDocument()
})
const header = screen.getByTestId('workflow-header')
expect(header).toHaveAttribute('data-show-env', 'false')
expect(header).toHaveAttribute('data-show-global-variable', 'false')
expect(header).toHaveAttribute('data-history-url', '/snippets/snippet-1/workflow-runs')
expect(screen.getByRole('button', { name: /snippet\.publishButton/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /snippet\.testRunButton/i })).toBeInTheDocument()
expect(screen.queryByText('snippet.viewOnly')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /snippet\.edit/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /snippet\.exitEditing/i })).not.toBeInTheDocument()
})
// Verifies forwarded callbacks still drive the snippet-specific controls.
describe('User Interactions', () => {
it('should invoke the snippet callbacks when save and discard are clicked in editing mode', () => {
render(
<SnippetHeader
snippetId="snippet-1"
canDiscardChanges
canSave
hasDraftChanges
isEditing
isPublishing={false}
onCancel={mockCancel}
onEdit={mockEdit}
onExitEditing={mockExitEditing}
onExitEditingWithoutSave={mockExitEditingWithoutSave}
onPublish={mockPublish}
onSaveAndExitEditing={mockSaveAndExit}
/>,
)
it('should publish from the primary header action', () => {
render(
<SnippetHeader
snippetId="snippet-1"
canSave
isPublishing={false}
onPublish={mockPublish}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /^snippet\.save$/i }))
fireEvent.click(screen.getByRole('button', { name: /snippet\.discardChanges/i }))
fireEvent.click(screen.getByRole('button', { name: /snippet\.publishButton/i }))
expect(mockPublish).toHaveBeenCalledTimes(1)
expect(mockCancel).toHaveBeenCalledTimes(1)
})
expect(mockPublish).toHaveBeenCalledTimes(1)
})
it('should disable save actions when the current graph has no nodes', () => {
render(
<SnippetHeader
snippetId="snippet-1"
canDiscardChanges
canSave={false}
hasDraftChanges
isEditing
isPublishing={false}
onCancel={mockCancel}
onEdit={mockEdit}
onExitEditing={mockExitEditing}
onExitEditingWithoutSave={mockExitEditingWithoutSave}
onPublish={mockPublish}
onSaveAndExitEditing={mockSaveAndExit}
/>,
)
it('should disable publish when the current graph has no nodes', () => {
render(
<SnippetHeader
snippetId="snippet-1"
canSave={false}
isPublishing={false}
onPublish={mockPublish}
/>,
)
expect(screen.getByRole('button', { name: /^snippet\.save$/i })).toBeDisabled()
expect(screen.getByRole('button', { name: /snippet\.saveAndExit/i })).toBeDisabled()
expect(screen.getByRole('button', { name: /snippet\.doNotSave/i })).not.toBeDisabled()
})
expect(screen.getByRole('button', { name: /snippet\.publishButton/i })).toBeDisabled()
})
it('should hide the discard draft action when there is no published workflow', () => {
render(
<SnippetHeader
snippetId="snippet-1"
canDiscardChanges={false}
canSave
hasDraftChanges
isEditing
isPublishing={false}
onCancel={mockCancel}
onEdit={mockEdit}
onExitEditing={mockExitEditing}
onExitEditingWithoutSave={mockExitEditingWithoutSave}
onPublish={mockPublish}
onSaveAndExitEditing={mockSaveAndExit}
/>,
)
it('should show publish loading state while publishing', () => {
render(
<SnippetHeader
snippetId="snippet-1"
canSave
isPublishing
onPublish={mockPublish}
/>,
)
expect(screen.queryByText('snippet.discardDraft')).not.toBeInTheDocument()
expect(screen.getByText('snippet.editingDraft')).toBeInTheDocument()
})
it('should enter editing mode from the readonly header action', () => {
render(
<SnippetHeader
snippetId="snippet-1"
canDiscardChanges
canSave
hasDraftChanges={false}
isEditing={false}
isPublishing={false}
onCancel={mockCancel}
onEdit={mockEdit}
onExitEditing={mockExitEditing}
onExitEditingWithoutSave={mockExitEditingWithoutSave}
onPublish={mockPublish}
onSaveAndExitEditing={mockSaveAndExit}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'snippet.edit' }))
expect(mockEdit).toHaveBeenCalledTimes(1)
})
it('should exit editing immediately when there are no draft changes', () => {
render(
<SnippetHeader
snippetId="snippet-1"
canDiscardChanges
canSave
hasDraftChanges={false}
isEditing
isPublishing={false}
onCancel={mockCancel}
onEdit={mockEdit}
onExitEditing={mockExitEditing}
onExitEditingWithoutSave={mockExitEditingWithoutSave}
onPublish={mockPublish}
onSaveAndExitEditing={mockSaveAndExit}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'snippet.exitEditing' }))
expect(mockExitEditing).toHaveBeenCalledTimes(1)
expect(mockExitEditingWithoutSave).not.toHaveBeenCalled()
expect(mockSaveAndExit).not.toHaveBeenCalled()
})
it('should disable edit actions while publishing', () => {
render(
<SnippetHeader
snippetId="snippet-1"
canDiscardChanges
canSave
hasDraftChanges
isEditing
isPublishing
onCancel={mockCancel}
onEdit={mockEdit}
onExitEditing={mockExitEditing}
onExitEditingWithoutSave={mockExitEditingWithoutSave}
onPublish={mockPublish}
onSaveAndExitEditing={mockSaveAndExit}
/>,
)
expect(screen.getByRole('button', { name: 'snippet.exitEditing' })).toBeDisabled()
expectLoadingButton(screen.getByRole('button', { name: /^snippet\.save$/i }))
expect(screen.getByRole('button', { name: 'snippet.doNotSave' })).toBeDisabled()
})
it('should discard changes from the exit confirmation dialog', async () => {
render(
<SnippetHeader
snippetId="snippet-1"
canDiscardChanges
canSave
hasDraftChanges
isEditing
isPublishing={false}
onCancel={mockCancel}
onEdit={mockEdit}
onExitEditing={mockExitEditing}
onExitEditingWithoutSave={mockExitEditingWithoutSave}
onPublish={mockPublish}
onSaveAndExitEditing={mockSaveAndExit}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'snippet.exitEditing' }))
fireEvent.click(screen.getByRole('button', { name: 'snippet.doNotSave' }))
await waitFor(() => {
expect(mockExitEditingWithoutSave).toHaveBeenCalledTimes(1)
})
expect(mockSaveAndExit).not.toHaveBeenCalled()
})
it('should save and exit from the exit confirmation dialog', async () => {
render(
<SnippetHeader
snippetId="snippet-1"
canDiscardChanges
canSave
hasDraftChanges
isEditing
isPublishing={false}
onCancel={mockCancel}
onEdit={mockEdit}
onExitEditing={mockExitEditing}
onExitEditingWithoutSave={mockExitEditingWithoutSave}
onPublish={mockPublish}
onSaveAndExitEditing={mockSaveAndExit}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'snippet.exitEditing' }))
fireEvent.click(screen.getByRole('button', { name: 'snippet.saveAndExit' }))
await waitFor(() => {
expect(mockSaveAndExit).toHaveBeenCalledTimes(1)
})
expect(mockExitEditingWithoutSave).not.toHaveBeenCalled()
})
expectLoadingButton(screen.getByRole('button', { name: /snippet\.publishButton/i }))
})
})

View File

@ -5,128 +5,42 @@ import { Button } from '@langgenius/dify-ui/button'
import {
memo,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import Header from '@/app/components/workflow/header'
import SaveBeforeLeavingDialog from '../save-before-leaving-dialog'
import CancelChanges from './cancel-changes'
import RunMode from './run-mode'
type SnippetHeaderProps = {
snippetId: string
canDiscardChanges: boolean
canEdit?: boolean
canSave: boolean
hasDraftChanges: boolean
isEditing: boolean
isPublishing: boolean
onCancel: () => void
onEdit: () => void
onExitEditing: () => void | Promise<void>
onExitEditingWithoutSave: () => void | Promise<void>
onPublish: () => void
onSaveAndExitEditing: () => void | Promise<void>
}
const ViewOnlyBadge = () => {
const { t } = useTranslation('snippet')
return (
<div className="rounded-md border border-components-badge-status-light-normal-border-inner bg-components-badge-bg-blue-light-soft px-1.5 py-0.5 system-xs-semibold-uppercase text-text-accent">
{t('viewOnly')}
</div>
)
}
const EditActions = ({
canEdit = true,
const PublishAction = ({
canSave,
hasDraftChanges,
isEditing,
isPublishing,
onEdit,
onExitEditing,
onExitEditingWithoutSave,
onPublish,
onSaveAndExitEditing,
}: Pick<SnippetHeaderProps, 'canEdit' | 'canSave' | 'hasDraftChanges' | 'isEditing' | 'isPublishing' | 'onEdit' | 'onExitEditing' | 'onExitEditingWithoutSave' | 'onPublish' | 'onSaveAndExitEditing'>) => {
}: Pick<SnippetHeaderProps, 'canSave' | 'isPublishing' | 'onPublish'>) => {
const { t } = useTranslation('snippet')
const [exitConfirmOpen, setExitConfirmOpen] = useState(false)
if (!isEditing) {
if (!canEdit)
return null
return (
<Button variant="primary" onClick={onEdit}>
{t('edit')}
</Button>
)
}
return (
<>
<SaveBeforeLeavingDialog
open={exitConfirmOpen}
onOpenChange={setExitConfirmOpen}
trigger={(
<Button
disabled={isPublishing || !canEdit}
onClick={(event) => {
if (!canEdit)
return
if (!hasDraftChanges) {
event.preventDefault()
void onExitEditing()
return
}
setExitConfirmOpen(true)
}}
>
{t('exitEditing')}
</Button>
)}
disabled={isPublishing || !canEdit}
saveDisabled={!canEdit || !canSave}
loading={isPublishing}
onDiscard={async () => {
await onExitEditingWithoutSave()
setExitConfirmOpen(false)
}}
onSave={async () => {
await onSaveAndExitEditing()
setExitConfirmOpen(false)
}}
/>
<Button
variant="primary"
loading={isPublishing}
disabled={isPublishing || !canEdit || !canSave}
onClick={onPublish}
>
{t('save')}
</Button>
</>
<Button
variant="primary"
loading={isPublishing}
disabled={isPublishing || !canSave}
onClick={onPublish}
>
{t('publishButton')}
</Button>
)
}
const SnippetHeader = ({
snippetId,
canDiscardChanges,
canEdit = true,
canSave,
hasDraftChanges,
isEditing,
isPublishing,
onCancel,
onEdit,
onExitEditing,
onExitEditingWithoutSave,
onPublish,
onSaveAndExitEditing,
}: SnippetHeaderProps) => {
const { t } = useTranslation('snippet')
const viewHistoryProps = useMemo(() => {
@ -139,21 +53,11 @@ const SnippetHeader = ({
return {
normal: {
components: {
title: isEditing
? (hasDraftChanges ? <CancelChanges canDiscardChanges={canDiscardChanges} onCancel={onCancel} /> : <></>)
: <ViewOnlyBadge />,
left: (
<EditActions
canEdit={canEdit}
<PublishAction
canSave={canSave}
hasDraftChanges={hasDraftChanges}
isEditing={isEditing}
isPublishing={isPublishing}
onEdit={onEdit}
onExitEditing={onExitEditing}
onExitEditingWithoutSave={onExitEditingWithoutSave}
onPublish={onPublish}
onSaveAndExitEditing={onSaveAndExitEditing}
/>
),
},
@ -174,7 +78,7 @@ const SnippetHeader = ({
viewHistoryProps,
},
}
}, [canDiscardChanges, canEdit, canSave, hasDraftChanges, isEditing, isPublishing, onCancel, onEdit, onExitEditing, onExitEditingWithoutSave, onPublish, onSaveAndExitEditing, t, viewHistoryProps])
}, [canSave, isPublishing, onPublish, t, viewHistoryProps])
return <Header {...headerProps} />
}

View File

@ -93,19 +93,12 @@ const hasSnippetDraftNodes = (payload?: Omit<SnippetDraftSyncPayload, 'hash'> |
const SnippetMainContent = ({
snippetId,
fields,
canDiscardChanges,
canEdit,
canSave,
hasDraftChanges,
isEditing,
onBeforePublish,
onCancel,
onDiscardRoute,
onEdit,
onExitEditing,
onExitEditingWithoutSave,
onSaved,
onSavedAndExitEditing,
}: SnippetMainContentProps) => {
const { push } = useRouter()
const { t } = useTranslation('snippet')
@ -134,12 +127,6 @@ const SnippetMainContent = ({
return didSave
}, [handlePublish, onBeforePublish, onSaved, t])
const handleSaveAndExitEditing = useCallback(async () => {
const didSave = await handlePublishSnippet()
if (didSave)
onSavedAndExitEditing()
}, [handlePublishSnippet, onSavedAndExitEditing])
const navigateToPendingHref = useCallback((href: string) => {
const url = new URL(href, window.location.href)
if (url.origin === window.location.origin)
@ -226,18 +213,9 @@ const SnippetMainContent = ({
<SnippetChildren
snippetId={snippetId}
fields={fields}
canDiscardChanges={canDiscardChanges}
canEdit={canEdit}
canSave={canSave}
hasDraftChanges={hasDraftChanges}
isEditing={isEditing}
isPublishing={isPublishing}
onCancel={onCancel}
onEdit={onEdit}
onExitEditing={onExitEditing}
onExitEditingWithoutSave={onExitEditingWithoutSave}
onPublish={handlePublishSnippet}
onSaveAndExitEditing={handleSaveAndExitEditing}
/>
<SaveBeforeLeavingDialog
open={!!pendingHref}