From 0ad268aa7d89fa321b3eb5f9c409d157a362e9d5 Mon Sep 17 00:00:00 2001 From: JzoNg Date: Sun, 29 Mar 2026 17:29:37 +0800 Subject: [PATCH] feat(web): snippet publish --- .../snippets/__tests__/index.spec.tsx | 20 +++++++++ .../__tests__/publish-menu.spec.tsx | 22 ++++++++++ .../__tests__/snippet-main.spec.tsx | 30 +++++++++++++ .../snippets/components/publish-menu.tsx | 35 +++++++++++---- .../snippets/components/snippet-children.tsx | 21 ++++----- .../snippet-header/__tests__/index.spec.tsx | 26 ++++++++--- .../components/snippet-header/index.tsx | 25 +++++++++-- .../components/snippet-header/publisher.tsx | 44 ++++++++++++++----- .../snippets/components/snippet-main.tsx | 36 +++++++++++++-- web/i18n/en-US/snippet.json | 2 + web/i18n/zh-Hans/snippet.json | 2 + 11 files changed, 223 insertions(+), 40 deletions(-) create mode 100644 web/app/components/snippets/components/__tests__/publish-menu.spec.tsx diff --git a/web/app/components/snippets/__tests__/index.spec.tsx b/web/app/components/snippets/__tests__/index.spec.tsx index 3c9a831035..42fe83704a 100644 --- a/web/app/components/snippets/__tests__/index.spec.tsx +++ b/web/app/components/snippets/__tests__/index.spec.tsx @@ -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() + + 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, diff --git a/web/app/components/snippets/components/__tests__/publish-menu.spec.tsx b/web/app/components/snippets/components/__tests__/publish-menu.spec.tsx new file mode 100644 index 0000000000..5b5a61343d --- /dev/null +++ b/web/app/components/snippets/components/__tests__/publish-menu.spec.tsx @@ -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( + , + ) + + 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) + }) +}) diff --git a/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx b/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx index 7550d7d045..ac6a45f4e4 100644 --- a/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx +++ b/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx @@ -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 }) => (
+
) diff --git a/web/app/components/snippets/components/snippet-children.tsx b/web/app/components/snippets/components/snippet-children.tsx index af5da29cb0..1547f7fa6e 100644 --- a/web/app/components/snippets/components/snippet-children.tsx +++ b/web/app/components/snippets/components/snippet-children.tsx @@ -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 = ({ - {isPublishMenuOpen && ( -
- -
- )} - {isInputPanelOpen && (
diff --git a/web/app/components/snippets/components/snippet-header/__tests__/index.spec.tsx b/web/app/components/snippets/components/snippet-header/__tests__/index.spec.tsx index 57216574e4..65975726ad 100644 --- a/web/app/components/snippets/components/snippet-header/__tests__/index.spec.tsx +++ b/web/app/components/snippets/components/snippet-header/__tests__/index.spec.tsx @@ -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', () => { , ) @@ -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( , ) @@ -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) }) }) }) diff --git a/web/app/components/snippets/components/snippet-header/index.tsx b/web/app/components/snippets/components/snippet-header/index.tsx index d25d9a5d17..d19a8e6967 100644 --- a/web/app/components/snippets/components/snippet-header/index.tsx +++ b/web/app/components/snippets/components/snippet-header/index.tsx @@ -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: , - middle: , + middle: ( + + ), }, controls: { showEnvButton: false, @@ -52,7 +69,7 @@ const SnippetHeader = ({ viewHistoryProps, }, } - }, [inputFieldCount, onToggleInputPanel, onTogglePublishMenu, viewHistoryProps]) + }, [inputFieldCount, isPublishMenuOpen, isPublishing, onPublish, onPublishMenuOpenChange, onToggleInputPanel, uiMeta, viewHistoryProps]) return
} diff --git a/web/app/components/snippets/components/snippet-header/publisher.tsx b/web/app/components/snippets/components/snippet-header/publisher.tsx index 0d4cf02611..25b79485c6 100644 --- a/web/app/components/snippets/components/snippet-header/publisher.tsx +++ b/web/app/components/snippets/components/snippet-header/publisher.tsx @@ -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 ( - + + + {t('publishButton')} + + + + + + ) } diff --git a/web/app/components/snippets/components/snippet-main.tsx b/web/app/components/snippets/components/snippet-main.tsx index 795d644752..b57fed1342 100644 --- a/web/app/components/snippets/components/snippet-main.tsx +++ b/web/app/components/snippets/components/snippet-main.tsx @@ -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(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} diff --git a/web/i18n/en-US/snippet.json b/web/i18n/en-US/snippet.json index 3bc86cfe43..fda6980f69 100644 --- a/web/i18n/en-US/snippet.json +++ b/web/i18n/en-US/snippet.json @@ -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", diff --git a/web/i18n/zh-Hans/snippet.json b/web/i18n/zh-Hans/snippet.json index bb4514d622..b8ed686559 100644 --- a/web/i18n/zh-Hans/snippet.json +++ b/web/i18n/zh-Hans/snippet.json @@ -24,7 +24,9 @@ "panelSecondaryGroup": "可选输入", "panelTitle": "输入字段", "publishButton": "发布", + "publishFailed": "发布 Snippet 失败", "publishMenuCurrentDraft": "当前草稿未发布", + "publishSuccess": "Snippet 已发布", "save": "保存", "sectionEvaluation": "评测", "sectionOrchestrate": "编排",