feat(web): snippet publish

This commit is contained in:
JzoNg 2026-03-29 17:29:37 +08:00
parent a4ea33167d
commit 0ad268aa7d
11 changed files with 223 additions and 40 deletions

View File

@ -6,6 +6,7 @@ import SnippetPage from '..'
import { useSnippetDetailStore } from '../store'
const mockUseSnippetInit = vi.fn()
const mockPublishSnippetMutateAsync = vi.fn()
vi.mock('../hooks/use-snippet-init', () => ({
useSnippetInit: (snippetId: string) => mockUseSnippetInit(snippetId),
@ -33,6 +34,13 @@ vi.mock('../hooks/use-snippet-refresh-draft', () => ({
}),
}))
vi.mock('@/service/use-snippet-workflows', () => ({
usePublishSnippetWorkflowMutation: () => ({
mutateAsync: mockPublishSnippetMutateAsync,
isPending: false,
}),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
replace: vi.fn(),
@ -186,6 +194,7 @@ describe('SnippetPage', () => {
beforeEach(() => {
vi.clearAllMocks()
useSnippetDetailStore.getState().reset()
mockPublishSnippetMutateAsync.mockResolvedValue(undefined)
mockUseSnippetInit.mockReturnValue({
data: mockSnippetDetail,
isLoading: false,
@ -221,6 +230,17 @@ describe('SnippetPage', () => {
expect(screen.getByText('snippet.publishMenuCurrentDraft')).toBeInTheDocument()
})
it('should publish the snippet when clicking publish in the menu', async () => {
render(<SnippetPage snippetId="snippet-1" />)
fireEvent.click(screen.getByRole('button', { name: /snippet\.publishButton/i }))
fireEvent.click(screen.getAllByRole('button', { name: /snippet\.publishButton/i })[1])
expect(mockPublishSnippetMutateAsync).toHaveBeenCalledWith({
params: { snippetId: 'snippet-1' },
})
})
it('should render loading fallback when snippet data is unavailable', () => {
mockUseSnippetInit.mockReturnValue({
data: null,

View File

@ -0,0 +1,22 @@
import { render, screen } from '@testing-library/react'
import PublishMenu from '../publish-menu'
describe('PublishMenu', () => {
it('should render the draft summary and publish shortcut', () => {
const { container } = render(
<PublishMenu
uiMeta={{
inputFieldCount: 1,
checklistCount: 2,
autoSavedAt: 'Auto-saved · a few seconds ago',
}}
onPublish={vi.fn()}
/>,
)
expect(screen.getByText('snippet.publishMenuCurrentDraft')).toBeInTheDocument()
expect(screen.getByText('Auto-saved · a few seconds ago')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'snippet.publishButton' })).toBeInTheDocument()
expect(container.querySelectorAll('.system-kbd')).toHaveLength(3)
})
})

View File

@ -10,8 +10,10 @@ const mockCloseEditor = vi.fn()
const mockOpenEditor = vi.fn()
const mockReset = vi.fn()
const mockSetInputPanelOpen = vi.fn()
const mockSetPublishMenuOpen = vi.fn()
const mockToggleInputPanel = vi.fn()
const mockTogglePublishMenu = vi.fn()
const mockPublishSnippetMutateAsync = vi.fn()
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => 'desktop',
@ -34,6 +36,7 @@ vi.mock('@/app/components/snippets/store', () => ({
openEditor: typeof mockOpenEditor
reset: typeof mockReset
setInputPanelOpen: typeof mockSetInputPanelOpen
setPublishMenuOpen: typeof mockSetPublishMenuOpen
toggleInputPanel: typeof mockToggleInputPanel
togglePublishMenu: typeof mockTogglePublishMenu
}) => unknown) => selector({
@ -45,11 +48,19 @@ vi.mock('@/app/components/snippets/store', () => ({
openEditor: mockOpenEditor,
reset: mockReset,
setInputPanelOpen: mockSetInputPanelOpen,
setPublishMenuOpen: mockSetPublishMenuOpen,
toggleInputPanel: mockToggleInputPanel,
togglePublishMenu: mockTogglePublishMenu,
}),
}))
vi.mock('@/service/use-snippet-workflows', () => ({
usePublishSnippetWorkflowMutation: () => ({
mutateAsync: mockPublishSnippetMutateAsync,
isPending: false,
}),
}))
vi.mock('@/app/components/snippets/hooks/use-configs-map', () => ({
useConfigsMap: () => ({
flowId: 'snippet-1',
@ -108,13 +119,16 @@ vi.mock('@/app/components/workflow', () => ({
vi.mock('@/app/components/snippets/components/snippet-children', () => ({
default: ({
onRemoveField,
onPublish,
onSubmitField,
}: {
onRemoveField: (index: number) => void
onPublish: () => void
onSubmitField: (field: SnippetInputField) => void
}) => (
<div>
<button type="button" onClick={() => onRemoveField(0)}>remove</button>
<button type="button" onClick={onPublish}>publish</button>
<button
type="button"
onClick={() => onSubmitField({
@ -178,6 +192,7 @@ describe('SnippetMain', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSyncInputFieldsDraft.mockResolvedValue(undefined)
mockPublishSnippetMutateAsync.mockResolvedValue(undefined)
})
describe('Input Fields Sync', () => {
@ -213,4 +228,19 @@ describe('SnippetMain', () => {
})
})
})
describe('Publish', () => {
it('should call the publish mutation and close the publish menu', async () => {
renderSnippetMain()
fireEvent.click(screen.getByRole('button', { name: 'publish' }))
await waitFor(() => {
expect(mockPublishSnippetMutateAsync).toHaveBeenCalledWith({
params: { snippetId: 'snippet-1' },
})
})
expect(mockSetPublishMenuOpen).toHaveBeenCalledWith(false)
})
})
})

View File

@ -3,24 +3,43 @@
import type { SnippetDetailUIModel } from '@/models/snippet'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
const PublishMenu = ({
uiMeta,
onPublish,
isPublishing = false,
}: {
uiMeta: SnippetDetailUIModel
onPublish: () => void
isPublishing?: boolean
}) => {
const { t } = useTranslation('snippet')
return (
<div className="w-80 rounded-2xl border border-components-panel-border bg-components-panel-bg p-4 shadow-[0px_20px_24px_-4px_rgba(9,9,11,0.08),0px_8px_8px_-4px_rgba(9,9,11,0.03)]">
<div className="text-text-tertiary system-xs-semibold-uppercase">
{t('publishMenuCurrentDraft')}
<div className="flex flex-col gap-3 px-4 pb-4 pt-3">
<div className="flex flex-col">
<div className="min-h-6 text-text-tertiary system-xs-medium-uppercase">
{t('publishMenuCurrentDraft')}
</div>
<div className="text-text-secondary system-sm-medium">
{uiMeta.autoSavedAt}
</div>
</div>
<div className="pt-1 text-text-secondary system-sm-medium">
{uiMeta.autoSavedAt}
</div>
<Button variant="primary" size="small" className="mt-4 w-full justify-center">
{t('publishButton')}
<Button
variant="primary"
loading={isPublishing}
disabled={isPublishing}
className="w-full justify-center gap-1.5"
onClick={onPublish}
>
<span>{t('publishButton')}</span>
<div aria-hidden="true">
<ShortcutsName
keys={['ctrl', 'shift', 'p']}
bgColor="white"
/>
</div>
</Button>
</div>
)

View File

@ -3,7 +3,6 @@
import type { SnippetDetailUIModel, SnippetInputField } from '@/models/snippet'
import SnippetInputFieldEditor from './input-field-editor'
import SnippetInputFieldPanel from './panel'
import PublishMenu from './publish-menu'
import SnippetHeader from './snippet-header'
import SnippetWorkflowPanel from './workflow-panel'
@ -15,9 +14,11 @@ type SnippetChildrenProps = {
isEditorOpen: boolean
isInputPanelOpen: boolean
isPublishMenuOpen: boolean
isPublishing: boolean
onToggleInputPanel: () => void
onTogglePublishMenu: () => void
onPublishMenuOpenChange: (open: boolean) => void
onCloseInputPanel: () => void
onPublish: () => void
onOpenEditor: (field?: SnippetInputField | null) => void
onCloseEditor: () => void
onSubmitField: (field: SnippetInputField) => void
@ -33,9 +34,11 @@ const SnippetChildren = ({
isEditorOpen,
isInputPanelOpen,
isPublishMenuOpen,
isPublishing,
onToggleInputPanel,
onTogglePublishMenu,
onPublishMenuOpenChange,
onCloseInputPanel,
onPublish,
onOpenEditor,
onCloseEditor,
onSubmitField,
@ -49,8 +52,12 @@ const SnippetChildren = ({
<SnippetHeader
snippetId={snippetId}
inputFieldCount={fields.length}
uiMeta={uiMeta}
isPublishMenuOpen={isPublishMenuOpen}
isPublishing={isPublishing}
onToggleInputPanel={onToggleInputPanel}
onTogglePublishMenu={onTogglePublishMenu}
onPublishMenuOpenChange={onPublishMenuOpenChange}
onPublish={onPublish}
/>
<SnippetWorkflowPanel
@ -67,12 +74,6 @@ const SnippetChildren = ({
onSortChange={onSortChange}
/>
{isPublishMenuOpen && (
<div className="absolute right-3 top-14 z-20">
<PublishMenu uiMeta={uiMeta} />
</div>
)}
{isInputPanelOpen && (
<div className="pointer-events-none absolute inset-y-3 right-3 z-30 flex justify-end">
<div className="pointer-events-auto h-full xl:hidden">

View File

@ -1,4 +1,5 @@
import type { HeaderProps } from '@/app/components/workflow/header'
import type { SnippetDetailUIModel } from '@/models/snippet'
import { fireEvent, render, screen } from '@testing-library/react'
import SnippetHeader from '..'
@ -23,7 +24,13 @@ vi.mock('@/app/components/workflow/header', () => ({
describe('SnippetHeader', () => {
const mockToggleInputPanel = vi.fn()
const mockTogglePublishMenu = vi.fn()
const mockPublishMenuOpenChange = vi.fn()
const mockPublish = vi.fn()
const uiMeta: SnippetDetailUIModel = {
inputFieldCount: 1,
checklistCount: 2,
autoSavedAt: 'Auto-saved · a few seconds ago',
}
beforeEach(() => {
vi.clearAllMocks()
@ -36,8 +43,12 @@ describe('SnippetHeader', () => {
<SnippetHeader
snippetId="snippet-1"
inputFieldCount={3}
uiMeta={uiMeta}
isPublishMenuOpen={false}
isPublishing={false}
onToggleInputPanel={mockToggleInputPanel}
onTogglePublishMenu={mockTogglePublishMenu}
onPublishMenuOpenChange={mockPublishMenuOpenChange}
onPublish={mockPublish}
/>,
)
@ -53,13 +64,17 @@ describe('SnippetHeader', () => {
// Verifies forwarded callbacks still drive the snippet-specific controls.
describe('User Interactions', () => {
it('should invoke the snippet callbacks when input and publish buttons are clicked', () => {
it('should invoke the snippet callbacks when input and publish trigger are clicked', () => {
render(
<SnippetHeader
snippetId="snippet-1"
inputFieldCount={1}
uiMeta={uiMeta}
isPublishMenuOpen={false}
isPublishing={false}
onToggleInputPanel={mockToggleInputPanel}
onTogglePublishMenu={mockTogglePublishMenu}
onPublishMenuOpenChange={mockPublishMenuOpenChange}
onPublish={mockPublish}
/>,
)
@ -67,7 +82,8 @@ describe('SnippetHeader', () => {
fireEvent.click(screen.getByRole('button', { name: /snippet\.publishButton/i }))
expect(mockToggleInputPanel).toHaveBeenCalledTimes(1)
expect(mockTogglePublishMenu).toHaveBeenCalledTimes(1)
expect(mockPublishMenuOpenChange).toHaveBeenCalledTimes(1)
expect(mockPublishMenuOpenChange.mock.calls[0][0]).toBe(true)
})
})
})

View File

@ -1,6 +1,7 @@
'use client'
import type { HeaderProps } from '@/app/components/workflow/header'
import type { SnippetDetailUIModel } from '@/models/snippet'
import {
memo,
useMemo,
@ -13,15 +14,23 @@ import RunMode from './run-mode'
type SnippetHeaderProps = {
snippetId: string
inputFieldCount: number
uiMeta: SnippetDetailUIModel
isPublishMenuOpen: boolean
isPublishing: boolean
onToggleInputPanel: () => void
onTogglePublishMenu: () => void
onPublishMenuOpenChange: (open: boolean) => void
onPublish: () => void
}
const SnippetHeader = ({
snippetId,
inputFieldCount,
uiMeta,
isPublishMenuOpen,
isPublishing,
onToggleInputPanel,
onTogglePublishMenu,
onPublishMenuOpenChange,
onPublish,
}: SnippetHeaderProps) => {
const viewHistoryProps = useMemo(() => {
return {
@ -34,7 +43,15 @@ const SnippetHeader = ({
normal: {
components: {
left: <InputFieldButton count={inputFieldCount} onClick={onToggleInputPanel} />,
middle: <Publisher onClick={onTogglePublishMenu} />,
middle: (
<Publisher
uiMeta={uiMeta}
open={isPublishMenuOpen}
isPublishing={isPublishing}
onOpenChange={onPublishMenuOpenChange}
onPublish={onPublish}
/>
),
},
controls: {
showEnvButton: false,
@ -52,7 +69,7 @@ const SnippetHeader = ({
viewHistoryProps,
},
}
}, [inputFieldCount, onToggleInputPanel, onTogglePublishMenu, viewHistoryProps])
}, [inputFieldCount, isPublishMenuOpen, isPublishing, onPublish, onPublishMenuOpenChange, onToggleInputPanel, uiMeta, viewHistoryProps])
return <Header {...headerProps} />
}

View File

@ -1,26 +1,50 @@
'use client'
import type { SnippetDetailUIModel } from '@/models/snippet'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import PublishMenu from '../publish-menu'
type PublisherProps = {
onClick: () => void
uiMeta: SnippetDetailUIModel
open: boolean
isPublishing: boolean
onOpenChange: (open: boolean) => void
onPublish: () => void
}
const Publisher = ({
onClick,
uiMeta,
open,
isPublishing,
onOpenChange,
onPublish,
}: PublisherProps) => {
const { t } = useTranslation('snippet')
return (
<button
type="button"
className="flex items-center gap-1 rounded-lg bg-components-button-primary-bg px-3 py-2 text-white shadow-[0px_2px_2px_-1px_rgba(0,0,0,0.12),0px_1px_1px_-1px_rgba(0,0,0,0.12),0px_0px_0px_0.5px_rgba(9,9,11,0.05)]"
onClick={onClick}
>
<span className="text-[13px] font-medium leading-4">{t('publishButton')}</span>
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4" />
</button>
<DropdownMenu open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger className="flex items-center gap-1 rounded-lg bg-components-button-primary-bg px-3 py-2 text-white shadow-[0px_2px_2px_-1px_rgba(0,0,0,0.12),0px_1px_1px_-1px_rgba(0,0,0,0.12),0px_0px_0px_0.5px_rgba(9,9,11,0.05)]">
<span className="text-[13px] font-medium leading-4">{t('publishButton')}</span>
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={6}
popupClassName="w-80 !rounded-2xl !bg-components-panel-bg !p-0 !shadow-[0px_20px_24px_-4px_rgba(9,9,11,0.08),0px_8px_8px_-4px_rgba(9,9,11,0.03)]"
>
<PublishMenu
uiMeta={uiMeta}
isPublishing={isPublishing}
onPublish={onPublish}
/>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -10,6 +10,10 @@ import {
RiTerminalWindowLine,
} from '@remixicon/react'
import {
useKeyPress,
} from 'ahooks'
import {
useCallback,
useEffect,
useMemo,
useState,
@ -25,7 +29,9 @@ import Evaluation from '@/app/components/evaluation'
import { WorkflowWithInnerContext } from '@/app/components/workflow'
import { useAvailableNodesMetaData } from '@/app/components/workflow-app/hooks'
import { BlockEnum } from '@/app/components/workflow/types'
import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { usePublishSnippetWorkflowMutation } from '@/service/use-snippet-workflows'
import { useConfigsMap } from '../hooks/use-configs-map'
import { useNodesSyncDraft } from '../hooks/use-nodes-sync-draft'
import { useSnippetRefreshDraft } from '../hooks/use-snippet-refresh-draft'
@ -61,6 +67,7 @@ const SnippetMain = ({
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const [fields, setFields] = useState<SnippetInputField[]>(payload.inputFields)
const publishSnippetMutation = usePublishSnippetWorkflowMutation(snippetId)
const {
doSyncWorkflowDraft,
syncInputFieldsDraft,
@ -97,8 +104,8 @@ const SnippetMain = ({
openEditor,
reset,
setInputPanelOpen,
setPublishMenuOpen,
toggleInputPanel,
togglePublishMenu,
} = useSnippetDetailStore(useShallow(state => ({
editingField: state.editingField,
isEditorOpen: state.isEditorOpen,
@ -108,8 +115,8 @@ const SnippetMain = ({
openEditor: state.openEditor,
reset: state.reset,
setInputPanelOpen: state.setInputPanelOpen,
setPublishMenuOpen: state.setPublishMenuOpen,
toggleInputPanel: state.toggleInputPanel,
togglePublishMenu: state.togglePublishMenu,
})))
useEffect(() => {
@ -166,6 +173,27 @@ const SnippetMain = ({
setInputPanelOpen(false)
}
const handlePublish = useCallback(async () => {
try {
await publishSnippetMutation.mutateAsync({
params: { snippetId },
})
setPublishMenuOpen(false)
toast.success(t('publishSuccess'))
}
catch (error) {
toast.error(error instanceof Error ? error.message : t('publishFailed'))
}
}, [publishSnippetMutation, setPublishMenuOpen, snippetId, t])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
if (section !== 'orchestrate' || publishSnippetMutation.isPending)
return
e.preventDefault()
void handlePublish()
}, { exactMatch: true, useCapture: true })
const hooksStore = useMemo(() => {
return {
doSyncWorkflowDraft,
@ -222,9 +250,11 @@ const SnippetMain = ({
isEditorOpen={isEditorOpen}
isInputPanelOpen={isInputPanelOpen}
isPublishMenuOpen={isPublishMenuOpen}
isPublishing={publishSnippetMutation.isPending}
onToggleInputPanel={handleToggleInputPanel}
onTogglePublishMenu={togglePublishMenu}
onPublishMenuOpenChange={setPublishMenuOpen}
onCloseInputPanel={handleCloseInputPanel}
onPublish={handlePublish}
onOpenEditor={openEditor}
onCloseEditor={closeEditor}
onSubmitField={handleSubmitField}

View File

@ -24,7 +24,9 @@
"panelSecondaryGroup": "Optional inputs",
"panelTitle": "Input Field",
"publishButton": "Publish",
"publishFailed": "Failed to publish snippet",
"publishMenuCurrentDraft": "Current draft unpublished",
"publishSuccess": "Snippet published",
"save": "Save",
"sectionEvaluation": "Evaluation",
"sectionOrchestrate": "Orchestrate",

View File

@ -24,7 +24,9 @@
"panelSecondaryGroup": "可选输入",
"panelTitle": "输入字段",
"publishButton": "发布",
"publishFailed": "发布 Snippet 失败",
"publishMenuCurrentDraft": "当前草稿未发布",
"publishSuccess": "Snippet 已发布",
"save": "保存",
"sectionEvaluation": "评测",
"sectionOrchestrate": "编排",