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

View File

@ -1,28 +1,8 @@
import type { ReactNode } from 'react'
import type { HeaderProps } from '@/app/components/workflow/header' 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 { expectLoadingButton } from '@/test/button'
import SnippetHeader from '..' 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', () => ({ vi.mock('@/app/components/workflow/header', () => ({
default: (props: HeaderProps) => { default: (props: HeaderProps) => {
return ( return (
@ -44,242 +24,71 @@ vi.mock('@/app/components/workflow/header', () => ({
})) }))
describe('SnippetHeader', () => { describe('SnippetHeader', () => {
const mockCancel = vi.fn()
const mockEdit = vi.fn()
const mockExitEditing = vi.fn()
const mockExitEditingWithoutSave = vi.fn()
const mockPublish = vi.fn() const mockPublish = vi.fn()
const mockSaveAndExit = vi.fn()
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
}) })
// Verifies the wrapper passes the expected workflow header configuration. it('should configure workflow header slots and hide workflow-only controls', () => {
describe('Rendering', () => { render(
it('should configure workflow header slots and hide workflow-only controls', () => { <SnippetHeader
render( snippetId="snippet-1"
<SnippetHeader canSave
snippetId="snippet-1" isPublishing={false}
canDiscardChanges onPublish={mockPublish}
canSave />,
hasDraftChanges={false} )
isEditing={false}
isPublishing={false}
onCancel={mockCancel}
onEdit={mockEdit}
onExitEditing={mockExitEditing}
onExitEditingWithoutSave={mockExitEditingWithoutSave}
onPublish={mockPublish}
onSaveAndExitEditing={mockSaveAndExit}
/>,
)
const header = screen.getByTestId('workflow-header') const header = screen.getByTestId('workflow-header')
expect(header).toHaveAttribute('data-show-env', 'false') expect(header).toHaveAttribute('data-show-env', 'false')
expect(header).toHaveAttribute('data-show-global-variable', 'false') expect(header).toHaveAttribute('data-show-global-variable', 'false')
expect(header).toHaveAttribute('data-history-url', '/snippets/snippet-1/workflow-runs') expect(header).toHaveAttribute('data-history-url', '/snippets/snippet-1/workflow-runs')
expect(screen.getByText('snippet.viewOnly')).toBeInTheDocument() expect(screen.getByRole('button', { name: /snippet\.publishButton/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /snippet\.edit/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /snippet\.testRunButton/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. it('should publish from the primary header action', () => {
describe('User Interactions', () => { render(
it('should invoke the snippet callbacks when save and discard are clicked in editing mode', () => { <SnippetHeader
render( snippetId="snippet-1"
<SnippetHeader canSave
snippetId="snippet-1" isPublishing={false}
canDiscardChanges onPublish={mockPublish}
canSave />,
hasDraftChanges )
isEditing
isPublishing={false}
onCancel={mockCancel}
onEdit={mockEdit}
onExitEditing={mockExitEditing}
onExitEditingWithoutSave={mockExitEditingWithoutSave}
onPublish={mockPublish}
onSaveAndExitEditing={mockSaveAndExit}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /^snippet\.save$/i })) fireEvent.click(screen.getByRole('button', { name: /snippet\.publishButton/i }))
fireEvent.click(screen.getByRole('button', { name: /snippet\.discardChanges/i }))
expect(mockPublish).toHaveBeenCalledTimes(1) expect(mockPublish).toHaveBeenCalledTimes(1)
expect(mockCancel).toHaveBeenCalledTimes(1) })
})
it('should disable save actions when the current graph has no nodes', () => { it('should disable publish when the current graph has no nodes', () => {
render( render(
<SnippetHeader <SnippetHeader
snippetId="snippet-1" snippetId="snippet-1"
canDiscardChanges canSave={false}
canSave={false} isPublishing={false}
hasDraftChanges onPublish={mockPublish}
isEditing />,
isPublishing={false} )
onCancel={mockCancel}
onEdit={mockEdit}
onExitEditing={mockExitEditing}
onExitEditingWithoutSave={mockExitEditingWithoutSave}
onPublish={mockPublish}
onSaveAndExitEditing={mockSaveAndExit}
/>,
)
expect(screen.getByRole('button', { name: /^snippet\.save$/i })).toBeDisabled() expect(screen.getByRole('button', { name: /snippet\.publishButton/i })).toBeDisabled()
expect(screen.getByRole('button', { name: /snippet\.saveAndExit/i })).toBeDisabled() })
expect(screen.getByRole('button', { name: /snippet\.doNotSave/i })).not.toBeDisabled()
})
it('should hide the discard draft action when there is no published workflow', () => { it('should show publish loading state while publishing', () => {
render( render(
<SnippetHeader <SnippetHeader
snippetId="snippet-1" snippetId="snippet-1"
canDiscardChanges={false} canSave
canSave isPublishing
hasDraftChanges onPublish={mockPublish}
isEditing />,
isPublishing={false} )
onCancel={mockCancel}
onEdit={mockEdit}
onExitEditing={mockExitEditing}
onExitEditingWithoutSave={mockExitEditingWithoutSave}
onPublish={mockPublish}
onSaveAndExitEditing={mockSaveAndExit}
/>,
)
expect(screen.queryByText('snippet.discardDraft')).not.toBeInTheDocument() expectLoadingButton(screen.getByRole('button', { name: /snippet\.publishButton/i }))
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()
})
}) })
}) })

View File

@ -5,128 +5,42 @@ import { Button } from '@langgenius/dify-ui/button'
import { import {
memo, memo,
useMemo, useMemo,
useState,
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Header from '@/app/components/workflow/header' import Header from '@/app/components/workflow/header'
import SaveBeforeLeavingDialog from '../save-before-leaving-dialog'
import CancelChanges from './cancel-changes'
import RunMode from './run-mode' import RunMode from './run-mode'
type SnippetHeaderProps = { type SnippetHeaderProps = {
snippetId: string snippetId: string
canDiscardChanges: boolean
canEdit?: boolean
canSave: boolean canSave: boolean
hasDraftChanges: boolean
isEditing: boolean
isPublishing: boolean isPublishing: boolean
onCancel: () => void
onEdit: () => void
onExitEditing: () => void | Promise<void>
onExitEditingWithoutSave: () => void | Promise<void>
onPublish: () => void onPublish: () => void
onSaveAndExitEditing: () => void | Promise<void>
} }
const ViewOnlyBadge = () => { const PublishAction = ({
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,
canSave, canSave,
hasDraftChanges,
isEditing,
isPublishing, isPublishing,
onEdit,
onExitEditing,
onExitEditingWithoutSave,
onPublish, onPublish,
onSaveAndExitEditing, }: Pick<SnippetHeaderProps, 'canSave' | 'isPublishing' | 'onPublish'>) => {
}: Pick<SnippetHeaderProps, 'canEdit' | 'canSave' | 'hasDraftChanges' | 'isEditing' | 'isPublishing' | 'onEdit' | 'onExitEditing' | 'onExitEditingWithoutSave' | 'onPublish' | 'onSaveAndExitEditing'>) => {
const { t } = useTranslation('snippet') 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 ( return (
<> <Button
<SaveBeforeLeavingDialog variant="primary"
open={exitConfirmOpen} loading={isPublishing}
onOpenChange={setExitConfirmOpen} disabled={isPublishing || !canSave}
trigger={( onClick={onPublish}
<Button >
disabled={isPublishing || !canEdit} {t('publishButton')}
onClick={(event) => { </Button>
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>
</>
) )
} }
const SnippetHeader = ({ const SnippetHeader = ({
snippetId, snippetId,
canDiscardChanges,
canEdit = true,
canSave, canSave,
hasDraftChanges,
isEditing,
isPublishing, isPublishing,
onCancel,
onEdit,
onExitEditing,
onExitEditingWithoutSave,
onPublish, onPublish,
onSaveAndExitEditing,
}: SnippetHeaderProps) => { }: SnippetHeaderProps) => {
const { t } = useTranslation('snippet') const { t } = useTranslation('snippet')
const viewHistoryProps = useMemo(() => { const viewHistoryProps = useMemo(() => {
@ -139,21 +53,11 @@ const SnippetHeader = ({
return { return {
normal: { normal: {
components: { components: {
title: isEditing
? (hasDraftChanges ? <CancelChanges canDiscardChanges={canDiscardChanges} onCancel={onCancel} /> : <></>)
: <ViewOnlyBadge />,
left: ( left: (
<EditActions <PublishAction
canEdit={canEdit}
canSave={canSave} canSave={canSave}
hasDraftChanges={hasDraftChanges}
isEditing={isEditing}
isPublishing={isPublishing} isPublishing={isPublishing}
onEdit={onEdit}
onExitEditing={onExitEditing}
onExitEditingWithoutSave={onExitEditingWithoutSave}
onPublish={onPublish} onPublish={onPublish}
onSaveAndExitEditing={onSaveAndExitEditing}
/> />
), ),
}, },
@ -174,7 +78,7 @@ const SnippetHeader = ({
viewHistoryProps, viewHistoryProps,
}, },
} }
}, [canDiscardChanges, canEdit, canSave, hasDraftChanges, isEditing, isPublishing, onCancel, onEdit, onExitEditing, onExitEditingWithoutSave, onPublish, onSaveAndExitEditing, t, viewHistoryProps]) }, [canSave, isPublishing, onPublish, t, viewHistoryProps])
return <Header {...headerProps} /> return <Header {...headerProps} />
} }

View File

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