From d6e247849f8647726ecd0f751ae829bc17d54765 Mon Sep 17 00:00:00 2001 From: kurokobo Date: Fri, 20 Mar 2026 15:07:32 +0900 Subject: [PATCH 1/5] fix: add max_retries=0 for executor (#33688) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- api/dify_graph/nodes/http_request/node.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/dify_graph/nodes/http_request/node.py b/api/dify_graph/nodes/http_request/node.py index 486ae241ee..3e5253d809 100644 --- a/api/dify_graph/nodes/http_request/node.py +++ b/api/dify_graph/nodes/http_request/node.py @@ -101,6 +101,9 @@ class HttpRequestNode(Node[HttpRequestNodeData]): timeout=self._get_request_timeout(self.node_data), variable_pool=self.graph_runtime_state.variable_pool, http_request_config=self._http_request_config, + # Must be 0 to disable executor-level retries, as the graph engine handles them. + # This is critical to prevent nested retries. + max_retries=0, ssl_verify=self.node_data.ssl_verify, http_client=self._http_client, file_manager=self._file_manager, From 978ebbf9ea7174525687390477cda53e144530cf Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:12:35 +0800 Subject: [PATCH 2/5] refactor: migrate high-risk overlay follow-up selectors (#33795) Signed-off-by: yyh Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../app/type-selector/index.spec.tsx | 33 ++-- .../components/app/type-selector/index.tsx | 128 ++++++++------ .../list/__tests__/create-card.spec.tsx | 18 +- .../__tests__/edit-pipeline-info.spec.tsx | 24 ++- .../template-card/__tests__/index.spec.tsx | 39 +++-- .../online-documents/__tests__/index.spec.tsx | 34 ++-- .../online-drive/__tests__/index.spec.tsx | 28 +-- .../base/options/__tests__/index.spec.tsx | 47 ++--- .../online-document-preview.spec.tsx | 26 ++- .../__tests__/components.spec.tsx | 29 +++- .../process-documents/__tests__/form.spec.tsx | 28 ++- .../__tests__/tts-params-panel.spec.tsx | 164 ++++++++++-------- .../model-selector/tts-params-panel.tsx | 69 ++++++-- .../create/__tests__/common-modal.spec.tsx | 7 +- .../tools/labels/__tests__/filter.spec.tsx | 97 ++--------- web/app/components/tools/labels/filter.tsx | 115 ++++++------ web/eslint-suppressions.json | 16 +- 17 files changed, 478 insertions(+), 424 deletions(-) diff --git a/web/app/components/app/type-selector/index.spec.tsx b/web/app/components/app/type-selector/index.spec.tsx index e24d963305..711678f0a8 100644 --- a/web/app/components/app/type-selector/index.spec.tsx +++ b/web/app/components/app/type-selector/index.spec.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, within } from '@testing-library/react' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import * as React from 'react' import { AppModeEnum } from '@/types/app' import AppTypeSelector, { AppTypeIcon, AppTypeLabel } from './index' @@ -14,7 +14,7 @@ describe('AppTypeSelector', () => { render() expect(screen.getByText('app.typeSelector.all')).toBeInTheDocument() - expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() + expect(screen.queryByText('app.typeSelector.workflow')).not.toBeInTheDocument() }) }) @@ -39,24 +39,27 @@ describe('AppTypeSelector', () => { // Covers opening/closing the dropdown and selection updates. describe('User interactions', () => { - it('should toggle option list when clicking the trigger', () => { + it('should close option list when clicking outside', () => { render() - expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() + expect(screen.queryByRole('list')).not.toBeInTheDocument() - fireEvent.click(screen.getByText('app.typeSelector.all')) - expect(screen.getByRole('tooltip')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'app.typeSelector.all' })) + expect(screen.getByRole('list')).toBeInTheDocument() - fireEvent.click(screen.getByText('app.typeSelector.all')) - expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() + fireEvent.pointerDown(document.body) + fireEvent.click(document.body) + return waitFor(() => { + expect(screen.queryByRole('list')).not.toBeInTheDocument() + }) }) it('should call onChange with added type when selecting an unselected item', () => { const onChange = vi.fn() render() - fireEvent.click(screen.getByText('app.typeSelector.all')) - fireEvent.click(within(screen.getByRole('tooltip')).getByText('app.typeSelector.workflow')) + fireEvent.click(screen.getByRole('button', { name: 'app.typeSelector.all' })) + fireEvent.click(within(screen.getByRole('list')).getByRole('button', { name: 'app.typeSelector.workflow' })) expect(onChange).toHaveBeenCalledWith([AppModeEnum.WORKFLOW]) }) @@ -65,8 +68,8 @@ describe('AppTypeSelector', () => { const onChange = vi.fn() render() - fireEvent.click(screen.getByText('app.typeSelector.workflow')) - fireEvent.click(within(screen.getByRole('tooltip')).getByText('app.typeSelector.workflow')) + fireEvent.click(screen.getByRole('button', { name: 'app.typeSelector.workflow' })) + fireEvent.click(within(screen.getByRole('list')).getByRole('button', { name: 'app.typeSelector.workflow' })) expect(onChange).toHaveBeenCalledWith([]) }) @@ -75,8 +78,8 @@ describe('AppTypeSelector', () => { const onChange = vi.fn() render() - fireEvent.click(screen.getByText('app.typeSelector.chatbot')) - fireEvent.click(within(screen.getByRole('tooltip')).getByText('app.typeSelector.agent')) + fireEvent.click(screen.getByRole('button', { name: 'app.typeSelector.chatbot' })) + fireEvent.click(within(screen.getByRole('list')).getByRole('button', { name: 'app.typeSelector.agent' })) expect(onChange).toHaveBeenCalledWith([AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT]) }) @@ -88,7 +91,7 @@ describe('AppTypeSelector', () => { fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' })) expect(onChange).toHaveBeenCalledWith([]) - expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() + expect(screen.queryByText('app.typeSelector.workflow')).not.toBeInTheDocument() }) }) }) diff --git a/web/app/components/app/type-selector/index.tsx b/web/app/components/app/type-selector/index.tsx index e97da4b7f3..a1475f9eff 100644 --- a/web/app/components/app/type-selector/index.tsx +++ b/web/app/components/app/type-selector/index.tsx @@ -4,13 +4,12 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { BubbleTextMod, ChatBot, ListSparkle, Logic } from '@/app/components/base/icons/src/vender/solid/communication' import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' + Popover, + PopoverContent, + PopoverTrigger, +} from '@/app/components/base/ui/popover' import { AppModeEnum } from '@/types/app' import { cn } from '@/utils/classnames' -import Checkbox from '../../base/checkbox' export type AppSelectorProps = { value: Array @@ -22,43 +21,43 @@ const allTypes: AppModeEnum[] = [AppModeEnum.WORKFLOW, AppModeEnum.ADVANCED_CHAT const AppTypeSelector = ({ value, onChange }: AppSelectorProps) => { const [open, setOpen] = useState(false) const { t } = useTranslation() + const triggerLabel = value.length === 0 + ? t('typeSelector.all', { ns: 'app' }) + : value.map(type => getAppTypeLabel(type, t)).join(', ') return ( -
- setOpen(v => !v)} - className="block" - > -
0 && 'pr-7', )} + > + + + {value.length > 0 && ( + - )} -
-
- -
    + + + )} + +
      {allTypes.map(mode => ( { /> ))}
    - +
-
+ ) } @@ -173,33 +172,54 @@ type AppTypeSelectorItemProps = { } function AppTypeSelectorItem({ checked, type, onClick }: AppTypeSelectorItemProps) { return ( -
  • - - -
    - -
    +
  • +
  • ) } +function getAppTypeLabel(type: AppModeEnum, t: ReturnType['t']) { + if (type === AppModeEnum.CHAT) + return t('typeSelector.chatbot', { ns: 'app' }) + if (type === AppModeEnum.AGENT_CHAT) + return t('typeSelector.agent', { ns: 'app' }) + if (type === AppModeEnum.COMPLETION) + return t('typeSelector.completion', { ns: 'app' }) + if (type === AppModeEnum.ADVANCED_CHAT) + return t('typeSelector.advanced', { ns: 'app' }) + if (type === AppModeEnum.WORKFLOW) + return t('typeSelector.workflow', { ns: 'app' }) + + return '' +} + type AppTypeLabelProps = { type: AppModeEnum className?: string } export function AppTypeLabel({ type, className }: AppTypeLabelProps) { const { t } = useTranslation() - let label = '' - if (type === AppModeEnum.CHAT) - label = t('typeSelector.chatbot', { ns: 'app' }) - if (type === AppModeEnum.AGENT_CHAT) - label = t('typeSelector.agent', { ns: 'app' }) - if (type === AppModeEnum.COMPLETION) - label = t('typeSelector.completion', { ns: 'app' }) - if (type === AppModeEnum.ADVANCED_CHAT) - label = t('typeSelector.advanced', { ns: 'app' }) - if (type === AppModeEnum.WORKFLOW) - label = t('typeSelector.workflow', { ns: 'app' }) - return {label} + return {getAppTypeLabel(type, t)} } diff --git a/web/app/components/datasets/create-from-pipeline/list/__tests__/create-card.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/__tests__/create-card.spec.tsx index c4702df9c7..7089d5c47e 100644 --- a/web/app/components/datasets/create-from-pipeline/list/__tests__/create-card.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/__tests__/create-card.spec.tsx @@ -13,12 +13,20 @@ vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn(), })) -vi.mock('@/app/components/base/toast', () => ({ - default: { - notify: vi.fn(), - }, +const { mockToastNotify } = vi.hoisted(() => ({ + mockToastNotify: vi.fn(), })) +vi.mock('@/app/components/base/toast', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + default: Object.assign(actual.default, { + notify: mockToastNotify, + }), + } +}) + const mockCreateEmptyDataset = vi.fn() const mockInvalidDatasetList = vi.fn() @@ -37,6 +45,8 @@ vi.mock('@/service/knowledge/use-dataset', () => ({ describe('CreateCard', () => { beforeEach(() => { vi.clearAllMocks() + mockToastNotify.mockReset() + mockToastNotify.mockImplementation(() => ({ clear: vi.fn() })) }) describe('Rendering', () => { diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/edit-pipeline-info.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/edit-pipeline-info.spec.tsx index 9c9c80c902..bb744c6c7f 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/edit-pipeline-info.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/edit-pipeline-info.spec.tsx @@ -1,8 +1,6 @@ import type { PipelineTemplate } from '@/models/pipeline' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - -import Toast from '@/app/components/base/toast' import { ChunkingMode } from '@/models/datasets' import EditPipelineInfo from '../edit-pipeline-info' @@ -16,12 +14,21 @@ vi.mock('@/service/use-pipeline', () => ({ useInvalidCustomizedTemplateList: () => mockInvalidCustomizedTemplateList, })) -vi.mock('@/app/components/base/toast', () => ({ - default: { - notify: vi.fn(), - }, +const { mockToastAdd } = vi.hoisted(() => ({ + mockToastAdd: vi.fn(), })) +vi.mock('@/app/components/base/ui/toast', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + toast: { + ...actual.toast, + add: mockToastAdd, + }, + } +}) + // Mock AppIconPicker to capture interactions let _mockOnSelect: ((icon: { type: 'emoji' | 'image', icon?: string, background?: string, fileId?: string, url?: string }) => void) | undefined let _mockOnClose: (() => void) | undefined @@ -88,6 +95,7 @@ describe('EditPipelineInfo', () => { beforeEach(() => { vi.clearAllMocks() + mockToastAdd.mockReset() _mockOnSelect = undefined _mockOnClose = undefined }) @@ -235,9 +243,9 @@ describe('EditPipelineInfo', () => { fireEvent.click(saveButton) await waitFor(() => { - expect(Toast.notify).toHaveBeenCalledWith({ + expect(mockToastAdd).toHaveBeenCalledWith({ type: 'error', - message: 'Please enter a name for the Knowledge Base.', + title: 'datasetPipeline.editPipelineInfoNameRequired', }) }) }) diff --git a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx index 3dcff12e9d..a6a3fb87ce 100644 --- a/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/template-card/__tests__/index.spec.tsx @@ -1,7 +1,6 @@ import type { PipelineTemplate } from '@/models/pipeline' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import Toast from '@/app/components/base/toast' import { ChunkingMode } from '@/models/datasets' import TemplateCard from '../index' @@ -15,12 +14,21 @@ vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: vi.fn(), })) -vi.mock('@/app/components/base/toast', () => ({ - default: { - notify: vi.fn(), - }, +const { mockToastAdd } = vi.hoisted(() => ({ + mockToastAdd: vi.fn(), })) +vi.mock('@/app/components/base/ui/toast', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + toast: { + ...actual.toast, + add: mockToastAdd, + }, + } +}) + // Mock download utilities vi.mock('@/utils/download', () => ({ downloadBlob: vi.fn(), @@ -174,6 +182,7 @@ describe('TemplateCard', () => { beforeEach(() => { vi.clearAllMocks() + mockToastAdd.mockReset() mockIsExporting = false _capturedOnConfirm = undefined _capturedOnCancel = undefined @@ -228,9 +237,9 @@ describe('TemplateCard', () => { fireEvent.click(chooseButton) await waitFor(() => { - expect(Toast.notify).toHaveBeenCalledWith({ + expect(mockToastAdd).toHaveBeenCalledWith({ type: 'error', - message: expect.any(String), + title: expect.any(String), }) }) }) @@ -291,9 +300,9 @@ describe('TemplateCard', () => { fireEvent.click(chooseButton) await waitFor(() => { - expect(Toast.notify).toHaveBeenCalledWith({ + expect(mockToastAdd).toHaveBeenCalledWith({ type: 'success', - message: expect.any(String), + title: expect.any(String), }) }) }) @@ -309,9 +318,9 @@ describe('TemplateCard', () => { fireEvent.click(chooseButton) await waitFor(() => { - expect(Toast.notify).toHaveBeenCalledWith({ + expect(mockToastAdd).toHaveBeenCalledWith({ type: 'error', - message: expect.any(String), + title: expect.any(String), }) }) }) @@ -458,9 +467,9 @@ describe('TemplateCard', () => { fireEvent.click(exportButton) await waitFor(() => { - expect(Toast.notify).toHaveBeenCalledWith({ + expect(mockToastAdd).toHaveBeenCalledWith({ type: 'success', - message: expect.any(String), + title: expect.any(String), }) }) }) @@ -476,9 +485,9 @@ describe('TemplateCard', () => { fireEvent.click(exportButton) await waitFor(() => { - expect(Toast.notify).toHaveBeenCalledWith({ + expect(mockToastAdd).toHaveBeenCalledWith({ type: 'error', - message: expect.any(String), + title: expect.any(String), }) }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/index.spec.tsx index 894ee60060..f072248de3 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/__tests__/index.spec.tsx @@ -32,16 +32,21 @@ vi.mock('@/service/base', () => ({ ssePost: mockSsePost, })) -// Mock Toast.notify - static method that manipulates DOM, needs mocking to verify calls -const { mockToastNotify } = vi.hoisted(() => ({ - mockToastNotify: vi.fn(), +// Mock toast.add because the component reports errors through the UI toast manager. +const { mockToastAdd } = vi.hoisted(() => ({ + mockToastAdd: vi.fn(), })) -vi.mock('@/app/components/base/toast', () => ({ - default: { - notify: mockToastNotify, - }, -})) +vi.mock('@/app/components/base/ui/toast', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + toast: { + ...actual.toast, + add: mockToastAdd, + }, + } +}) // Mock useGetDataSourceAuth - API service hook requires mocking const { mockUseGetDataSourceAuth } = vi.hoisted(() => ({ @@ -192,6 +197,7 @@ const createDefaultProps = (overrides?: Partial): OnlineDo describe('OnlineDocuments', () => { beforeEach(() => { vi.clearAllMocks() + mockToastAdd.mockReset() // Reset store state mockStoreState.documentsData = [] @@ -509,9 +515,9 @@ describe('OnlineDocuments', () => { render() await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith({ + expect(mockToastAdd).toHaveBeenCalledWith({ type: 'error', - message: 'Something went wrong', + title: 'Something went wrong', }) }) }) @@ -774,9 +780,9 @@ describe('OnlineDocuments', () => { render() await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith({ + expect(mockToastAdd).toHaveBeenCalledWith({ type: 'error', - message: 'API Error Message', + title: 'API Error Message', }) }) }) @@ -1094,9 +1100,9 @@ describe('OnlineDocuments', () => { render() await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith({ + expect(mockToastAdd).toHaveBeenCalledWith({ type: 'error', - message: 'Failed to fetch documents', + title: 'Failed to fetch documents', }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/index.spec.tsx index 1721b72e1c..418ceee442 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/__tests__/index.spec.tsx @@ -45,15 +45,20 @@ vi.mock('@/service/use-datasource', () => ({ useGetDataSourceAuth: mockUseGetDataSourceAuth, })) -const { mockToastNotify } = vi.hoisted(() => ({ - mockToastNotify: vi.fn(), +const { mockToastAdd } = vi.hoisted(() => ({ + mockToastAdd: vi.fn(), })) -vi.mock('@/app/components/base/toast', () => ({ - default: { - notify: mockToastNotify, - }, -})) +vi.mock('@/app/components/base/ui/toast', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + toast: { + ...actual.toast, + add: mockToastAdd, + }, + } +}) // Note: zustand/react/shallow useShallow is imported directly (simple utility function) @@ -231,6 +236,7 @@ const resetMockStoreState = () => { describe('OnlineDrive', () => { beforeEach(() => { vi.clearAllMocks() + mockToastAdd.mockReset() // Reset store state resetMockStoreState() @@ -541,9 +547,9 @@ describe('OnlineDrive', () => { render() await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith({ + expect(mockToastAdd).toHaveBeenCalledWith({ type: 'error', - message: errorMessage, + title: errorMessage, }) }) }) @@ -915,9 +921,9 @@ describe('OnlineDrive', () => { render() await waitFor(() => { - expect(mockToastNotify).toHaveBeenCalledWith({ + expect(mockToastAdd).toHaveBeenCalledWith({ type: 'error', - message: errorMessage, + title: errorMessage, }) }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/__tests__/index.spec.tsx index c147e969a6..d47b083f35 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/__tests__/index.spec.tsx @@ -1,13 +1,26 @@ -import type { MockInstance } from 'vitest' import type { RAGPipelineVariables } from '@/models/pipeline' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types' -import Toast from '@/app/components/base/toast' import { CrawlStep } from '@/models/datasets' import { PipelineInputVarType } from '@/models/pipeline' import Options from '../index' +const { mockToastAdd } = vi.hoisted(() => ({ + mockToastAdd: vi.fn(), +})) + +vi.mock('@/app/components/base/ui/toast', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + toast: { + ...actual.toast, + add: mockToastAdd, + }, + } +}) + // Mock useInitialData and useConfigurations hooks const { mockUseInitialData, mockUseConfigurations } = vi.hoisted(() => ({ mockUseInitialData: vi.fn(), @@ -116,13 +129,9 @@ const createDefaultProps = (overrides?: Partial): OptionsProps => }) describe('Options', () => { - let toastNotifySpy: MockInstance - beforeEach(() => { vi.clearAllMocks() - - // Spy on Toast.notify instead of mocking the entire module - toastNotifySpy = vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() })) + mockToastAdd.mockReset() // Reset mock form values Object.keys(mockFormValues).forEach(key => delete mockFormValues[key]) @@ -132,10 +141,6 @@ describe('Options', () => { mockUseConfigurations.mockReturnValue([createMockConfiguration()]) }) - afterEach(() => { - toastNotifySpy.mockRestore() - }) - describe('Rendering', () => { it('should render without crashing', () => { const props = createDefaultProps() @@ -638,7 +643,7 @@ describe('Options', () => { fireEvent.click(screen.getByRole('button')) // Assert - Toast should be called with error message - expect(toastNotifySpy).toHaveBeenCalledWith( + expect(mockToastAdd).toHaveBeenCalledWith( expect.objectContaining({ type: 'error', }), @@ -660,10 +665,10 @@ describe('Options', () => { fireEvent.click(screen.getByRole('button')) // Assert - Toast message should contain field path - expect(toastNotifySpy).toHaveBeenCalledWith( + expect(mockToastAdd).toHaveBeenCalledWith( expect.objectContaining({ type: 'error', - message: expect.stringContaining('email_address'), + title: expect.stringContaining('email_address'), }), ) }) @@ -714,8 +719,8 @@ describe('Options', () => { fireEvent.click(screen.getByRole('button')) // Assert - Toast should be called once (only first error) - expect(toastNotifySpy).toHaveBeenCalledTimes(1) - expect(toastNotifySpy).toHaveBeenCalledWith( + expect(mockToastAdd).toHaveBeenCalledTimes(1) + expect(mockToastAdd).toHaveBeenCalledWith( expect.objectContaining({ type: 'error', }), @@ -738,7 +743,7 @@ describe('Options', () => { fireEvent.click(screen.getByRole('button')) // Assert - No toast error, onSubmit called - expect(toastNotifySpy).not.toHaveBeenCalled() + expect(mockToastAdd).not.toHaveBeenCalled() expect(mockOnSubmit).toHaveBeenCalled() }) @@ -835,7 +840,7 @@ describe('Options', () => { fireEvent.click(screen.getByRole('button')) expect(mockOnSubmit).toHaveBeenCalled() - expect(toastNotifySpy).not.toHaveBeenCalled() + expect(mockToastAdd).not.toHaveBeenCalled() }) it('should fail validation with invalid data', () => { @@ -854,7 +859,7 @@ describe('Options', () => { fireEvent.click(screen.getByRole('button')) expect(mockOnSubmit).not.toHaveBeenCalled() - expect(toastNotifySpy).toHaveBeenCalled() + expect(mockToastAdd).toHaveBeenCalled() }) it('should show error toast message when validation fails', () => { @@ -871,10 +876,10 @@ describe('Options', () => { fireEvent.click(screen.getByRole('button')) - expect(toastNotifySpy).toHaveBeenCalledWith( + expect(mockToastAdd).toHaveBeenCalledWith( expect.objectContaining({ type: 'error', - message: expect.any(String), + title: expect.any(String), }), ) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/online-document-preview.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/online-document-preview.spec.tsx index 947313cda5..998f34540b 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/online-document-preview.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/preview/__tests__/online-document-preview.spec.tsx @@ -1,13 +1,24 @@ import type { NotionPage } from '@/models/common' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' -import Toast from '@/app/components/base/toast' import OnlineDocumentPreview from '../online-document-preview' // Uses global react-i18next mock from web/vitest.setup.ts -// Spy on Toast.notify -const toastNotifySpy = vi.spyOn(Toast, 'notify') +const { mockToastAdd } = vi.hoisted(() => ({ + mockToastAdd: vi.fn(), +})) + +vi.mock('@/app/components/base/ui/toast', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + toast: { + ...actual.toast, + add: mockToastAdd, + }, + } +}) // Mock dataset-detail context - needs mock to control return values const mockPipelineId = vi.fn() @@ -56,6 +67,7 @@ const defaultProps = { describe('OnlineDocumentPreview', () => { beforeEach(() => { vi.clearAllMocks() + mockToastAdd.mockReset() mockPipelineId.mockReturnValue('pipeline-123') mockUsePreviewOnlineDocument.mockReturnValue({ mutateAsync: mockMutateAsync, @@ -258,9 +270,9 @@ describe('OnlineDocumentPreview', () => { render() await waitFor(() => { - expect(toastNotifySpy).toHaveBeenCalledWith({ + expect(mockToastAdd).toHaveBeenCalledWith({ type: 'error', - message: errorMessage, + title: errorMessage, }) }) }) @@ -276,9 +288,9 @@ describe('OnlineDocumentPreview', () => { render() await waitFor(() => { - expect(toastNotifySpy).toHaveBeenCalledWith({ + expect(mockToastAdd).toHaveBeenCalledWith({ type: 'error', - message: 'Network Error', + title: 'Network Error', }) }) }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/components.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/components.spec.tsx index c82b5a8468..31363f8784 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/components.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/components.spec.tsx @@ -3,13 +3,24 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' import * as z from 'zod' import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types' -import Toast from '@/app/components/base/toast' import Actions from '../actions' import Form from '../form' import Header from '../header' -// Spy on Toast.notify for validation tests -const toastNotifySpy = vi.spyOn(Toast, 'notify') +const { mockToastAdd } = vi.hoisted(() => ({ + mockToastAdd: vi.fn(), +})) + +vi.mock('@/app/components/base/ui/toast', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + toast: { + ...actual.toast, + add: mockToastAdd, + }, + } +}) // Test Data Factory Functions @@ -335,7 +346,7 @@ describe('Form', () => { beforeEach(() => { vi.clearAllMocks() - toastNotifySpy.mockClear() + mockToastAdd.mockReset() }) describe('Rendering', () => { @@ -444,9 +455,9 @@ describe('Form', () => { // Assert - validation error should be shown await waitFor(() => { - expect(toastNotifySpy).toHaveBeenCalledWith({ + expect(mockToastAdd).toHaveBeenCalledWith({ type: 'error', - message: '"field1" is required', + title: '"field1" is required', }) }) }) @@ -566,9 +577,9 @@ describe('Form', () => { fireEvent.submit(form) await waitFor(() => { - expect(toastNotifySpy).toHaveBeenCalledWith({ + expect(mockToastAdd).toHaveBeenCalledWith({ type: 'error', - message: '"field1" is required', + title: '"field1" is required', }) }) }) @@ -583,7 +594,7 @@ describe('Form', () => { // Assert - wait a bit and verify onSubmit was not called await waitFor(() => { - expect(toastNotifySpy).toHaveBeenCalled() + expect(mockToastAdd).toHaveBeenCalled() }) expect(onSubmit).not.toHaveBeenCalled() }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/form.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/form.spec.tsx index 25ac817284..9b13ce8132 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/form.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/__tests__/form.spec.tsx @@ -2,10 +2,23 @@ import type { BaseConfiguration } from '@/app/components/base/form/form-scenario import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' -import Toast from '@/app/components/base/toast' - import Form from '../form' +const { mockToastAdd } = vi.hoisted(() => ({ + mockToastAdd: vi.fn(), +})) + +vi.mock('@/app/components/base/ui/toast', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + toast: { + ...actual.toast, + add: mockToastAdd, + }, + } +}) + // Mock the Header component (sibling component, not a base component) vi.mock('../header', () => ({ default: ({ onReset, resetDisabled, onPreview, previewDisabled }: { @@ -44,7 +57,7 @@ const defaultProps = { describe('Form (process-documents)', () => { beforeEach(() => { vi.clearAllMocks() - vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() })) + mockToastAdd.mockReset() }) // Verify basic rendering of form structure @@ -106,8 +119,11 @@ describe('Form (process-documents)', () => { fireEvent.submit(form) await waitFor(() => { - expect(Toast.notify).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error' }), + expect(mockToastAdd).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + title: '"name" Name is required', + }), ) }) }) @@ -121,7 +137,7 @@ describe('Form (process-documents)', () => { await waitFor(() => { expect(defaultProps.onSubmit).toHaveBeenCalled() }) - expect(Toast.notify).not.toHaveBeenCalled() + expect(mockToastAdd).not.toHaveBeenCalled() }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/tts-params-panel.spec.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/tts-params-panel.spec.tsx index a5633b30d1..94ac5ab05a 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/tts-params-panel.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/__tests__/tts-params-panel.spec.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' +import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' // Import component after mocks @@ -17,44 +18,73 @@ vi.mock('@/i18n-config/language', () => ({ ], })) -// Mock PortalSelect component -vi.mock('@/app/components/base/select', () => ({ - PortalSelect: ({ +const MockSelectContext = React.createContext<{ + value: string + onValueChange: (value: string) => void +}>({ + value: '', + onValueChange: () => {}, +}) + +vi.mock('@/app/components/base/ui/select', () => ({ + Select: ({ value, - items, - onSelect, - triggerClassName, - popupClassName, - popupInnerClassName, + onValueChange, + children, }: { value: string - items: Array<{ value: string, name: string }> - onSelect: (item: { value: string }) => void - triggerClassName?: string - popupClassName?: string - popupInnerClassName?: string + onValueChange: (value: string) => void + children: React.ReactNode }) => ( -
    - {value} -
    - {items.map(item => ( - - ))} -
    + +
    {children}
    +
    + ), + SelectTrigger: ({ + children, + className, + 'data-testid': testId, + }: { + 'children': React.ReactNode + 'className'?: string + 'data-testid'?: string + }) => ( + + ), + SelectValue: () => { + const { value } = React.useContext(MockSelectContext) + return {value} + }, + SelectContent: ({ + children, + popupClassName, + }: { + children: React.ReactNode + popupClassName?: string + }) => ( +
    + {children}
    ), + SelectItem: ({ + children, + value, + }: { + children: React.ReactNode + value: string + }) => { + const { onValueChange } = React.useContext(MockSelectContext) + return ( + + ) + }, })) // ==================== Test Utilities ==================== @@ -139,7 +169,7 @@ describe('TTSParamsPanel', () => { expect(screen.getByText('appDebug.voice.voiceSettings.voice')).toBeInTheDocument() }) - it('should render two PortalSelect components', () => { + it('should render two Select components', () => { // Arrange const props = createDefaultProps() @@ -147,7 +177,7 @@ describe('TTSParamsPanel', () => { render() // Assert - const selects = screen.getAllByTestId('portal-select') + const selects = screen.getAllByTestId('select-root') expect(selects).toHaveLength(2) }) @@ -159,8 +189,8 @@ describe('TTSParamsPanel', () => { render() // Assert - const selects = screen.getAllByTestId('portal-select') - expect(selects[0]).toHaveAttribute('data-value', 'zh-Hans') + const values = screen.getAllByTestId('selected-value') + expect(values[0]).toHaveTextContent('zh-Hans') }) it('should render voice select with correct value', () => { @@ -171,8 +201,8 @@ describe('TTSParamsPanel', () => { render() // Assert - const selects = screen.getAllByTestId('portal-select') - expect(selects[1]).toHaveAttribute('data-value', 'echo') + const values = screen.getAllByTestId('selected-value') + expect(values[1]).toHaveTextContent('echo') }) it('should only show supported languages in language select', () => { @@ -205,7 +235,7 @@ describe('TTSParamsPanel', () => { // ==================== Props Testing ==================== describe('Props', () => { - it('should apply trigger className to PortalSelect', () => { + it('should apply trigger className to SelectTrigger', () => { // Arrange const props = createDefaultProps() @@ -213,12 +243,11 @@ describe('TTSParamsPanel', () => { render() // Assert - const selects = screen.getAllByTestId('portal-select') - expect(selects[0]).toHaveAttribute('data-trigger-class', 'h-8') - expect(selects[1]).toHaveAttribute('data-trigger-class', 'h-8') + expect(screen.getByTestId('tts-language-select-trigger')).toHaveAttribute('data-class', 'w-full') + expect(screen.getByTestId('tts-voice-select-trigger')).toHaveAttribute('data-class', 'w-full') }) - it('should apply popup className to PortalSelect', () => { + it('should apply popup className to SelectContent', () => { // Arrange const props = createDefaultProps() @@ -226,22 +255,9 @@ describe('TTSParamsPanel', () => { render() // Assert - const selects = screen.getAllByTestId('portal-select') - expect(selects[0]).toHaveAttribute('data-popup-class', 'z-[1000]') - expect(selects[1]).toHaveAttribute('data-popup-class', 'z-[1000]') - }) - - it('should apply popup inner className to PortalSelect', () => { - // Arrange - const props = createDefaultProps() - - // Act - render() - - // Assert - const selects = screen.getAllByTestId('portal-select') - expect(selects[0]).toHaveAttribute('data-popup-inner-class', 'w-[354px]') - expect(selects[1]).toHaveAttribute('data-popup-inner-class', 'w-[354px]') + const contents = screen.getAllByTestId('select-content') + expect(contents[0]).toHaveAttribute('data-popup-class', 'w-[354px]') + expect(contents[1]).toHaveAttribute('data-popup-class', 'w-[354px]') }) }) @@ -411,10 +427,8 @@ describe('TTSParamsPanel', () => { render() // Assert - no voice items (except language items) - const voiceSelects = screen.getAllByTestId('portal-select') - // Second select is voice select, should have no voice items in items-container - const voiceItemsContainer = voiceSelects[1].querySelector('[data-testid="items-container"]') - expect(voiceItemsContainer?.children).toHaveLength(0) + expect(screen.getAllByTestId('select-content')[1].children).toHaveLength(0) + expect(screen.queryByTestId('select-item-alloy')).not.toBeInTheDocument() }) it('should handle currentModel with single voice', () => { @@ -443,8 +457,8 @@ describe('TTSParamsPanel', () => { render() // Assert - const selects = screen.getAllByTestId('portal-select') - expect(selects[0]).toHaveAttribute('data-value', '') + const values = screen.getAllByTestId('selected-value') + expect(values[0]).toHaveTextContent('') }) it('should handle empty voice value', () => { @@ -455,8 +469,8 @@ describe('TTSParamsPanel', () => { render() // Assert - const selects = screen.getAllByTestId('portal-select') - expect(selects[1]).toHaveAttribute('data-value', '') + const values = screen.getAllByTestId('selected-value') + expect(values[1]).toHaveTextContent('') }) it('should handle many voices', () => { @@ -514,14 +528,14 @@ describe('TTSParamsPanel', () => { // Act const { rerender } = render() - const selects = screen.getAllByTestId('portal-select') - expect(selects[0]).toHaveAttribute('data-value', 'en-US') + const values = screen.getAllByTestId('selected-value') + expect(values[0]).toHaveTextContent('en-US') rerender() // Assert - const updatedSelects = screen.getAllByTestId('portal-select') - expect(updatedSelects[0]).toHaveAttribute('data-value', 'zh-Hans') + const updatedValues = screen.getAllByTestId('selected-value') + expect(updatedValues[0]).toHaveTextContent('zh-Hans') }) it('should update when voice prop changes', () => { @@ -530,14 +544,14 @@ describe('TTSParamsPanel', () => { // Act const { rerender } = render() - const selects = screen.getAllByTestId('portal-select') - expect(selects[1]).toHaveAttribute('data-value', 'alloy') + const values = screen.getAllByTestId('selected-value') + expect(values[1]).toHaveTextContent('alloy') rerender() // Assert - const updatedSelects = screen.getAllByTestId('portal-select') - expect(updatedSelects[1]).toHaveAttribute('data-value', 'echo') + const updatedValues = screen.getAllByTestId('selected-value') + expect(updatedValues[1]).toHaveTextContent('echo') }) it('should update voice list when currentModel changes', () => { diff --git a/web/app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.tsx b/web/app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.tsx index 97947f48c1..461b229602 100644 --- a/web/app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.tsx +++ b/web/app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.tsx @@ -1,9 +1,8 @@ import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { PortalSelect } from '@/app/components/base/select' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select' import { languages } from '@/i18n-config/language' -import { cn } from '@/utils/classnames' type Props = { currentModel: any @@ -12,6 +11,8 @@ type Props = { onChange: (language: string, voice: string) => void } +const supportedLanguages = languages.filter(item => item.supported) + const TTSParamsPanel = ({ currentModel, language, @@ -19,11 +20,11 @@ const TTSParamsPanel = ({ onChange, }: Props) => { const { t } = useTranslation() - const voiceList = useMemo(() => { + const voiceList = useMemo>(() => { if (!currentModel) return [] - return currentModel.model_properties.voices.map((item: { mode: any }) => ({ - ...item, + return currentModel.model_properties.voices.map((item: { mode: string, name: string }) => ({ + label: item.name, value: item.mode, })) }, [currentModel]) @@ -39,27 +40,57 @@ const TTSParamsPanel = ({
    {t('voice.voiceSettings.language', { ns: 'appDebug' })}
    - item.supported)} - onSelect={item => setLanguage(item.value as string)} - /> + onValueChange={(value) => { + if (value == null) + return + setLanguage(value) + }} + > + + + + + {supportedLanguages.map(item => ( + + {item.name} + + ))} + +
    {t('voice.voiceSettings.voice', { ns: 'appDebug' })}
    - setVoice(item.value as string)} - /> + onValueChange={(value) => { + if (value == null) + return + setVoice(value) + }} + > + + + + + {voiceList.map(item => ( + + {item.label} + + ))} + +
    ) diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx index b9953bd249..21a4c3defa 100644 --- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/__tests__/common-modal.spec.tsx @@ -1333,12 +1333,9 @@ describe('CommonCreateModal', () => { mockVerifyCredentials.mockImplementation((params, { onSuccess }) => { onSuccess() }) + const builder = createMockSubscriptionBuilder() - render() - - await waitFor(() => { - expect(mockCreateBuilder).toHaveBeenCalled() - }) + render() fireEvent.click(screen.getByTestId('modal-confirm')) diff --git a/web/app/components/tools/labels/__tests__/filter.spec.tsx b/web/app/components/tools/labels/__tests__/filter.spec.tsx index 7b88cb1bbd..4dc6a8f88c 100644 --- a/web/app/components/tools/labels/__tests__/filter.spec.tsx +++ b/web/app/components/tools/labels/__tests__/filter.spec.tsx @@ -18,32 +18,11 @@ vi.mock('@/app/components/plugins/hooks', () => ({ }), })) -// Mock useDebounceFn to store the function and allow manual triggering -let debouncedFn: (() => void) | null = null -vi.mock('ahooks', () => ({ - useDebounceFn: (fn: () => void) => { - debouncedFn = fn - return { - run: () => { - // Schedule to run after React state updates - setTimeout(() => debouncedFn?.(), 0) - }, - cancel: vi.fn(), - } - }, -})) - describe('LabelFilter', () => { const mockOnChange = vi.fn() beforeEach(() => { vi.clearAllMocks() - vi.useFakeTimers() - debouncedFn = null - }) - - afterEach(() => { - vi.useRealTimers() }) // Rendering Tests @@ -81,36 +60,23 @@ describe('LabelFilter', () => { const trigger = screen.getByText('common.tag.placeholder') - await act(async () => { - fireEvent.click(trigger) - vi.advanceTimersByTime(10) - }) + await act(async () => fireEvent.click(trigger)) mockTags.forEach((tag) => { expect(screen.getByText(tag.label)).toBeInTheDocument() }) }) - it('should close dropdown when trigger is clicked again', async () => { + it('should render search input when dropdown is open', async () => { render() - const trigger = screen.getByText('common.tag.placeholder') + const trigger = screen.getByText('common.tag.placeholder').closest('button') + expect(trigger).toBeInTheDocument() - // Open - await act(async () => { - fireEvent.click(trigger) - vi.advanceTimersByTime(10) - }) + await act(async () => fireEvent.click(trigger!)) expect(screen.getByText('Agent')).toBeInTheDocument() - - // Close - await act(async () => { - fireEvent.click(trigger) - vi.advanceTimersByTime(10) - }) - - expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + expect(screen.getByRole('textbox')).toBeInTheDocument() }) }) @@ -119,17 +85,11 @@ describe('LabelFilter', () => { it('should call onChange with selected label when clicking a label', async () => { render() - await act(async () => { - fireEvent.click(screen.getByText('common.tag.placeholder')) - vi.advanceTimersByTime(10) - }) + await act(async () => fireEvent.click(screen.getByText('common.tag.placeholder'))) expect(screen.getByText('Agent')).toBeInTheDocument() - await act(async () => { - fireEvent.click(screen.getByText('Agent')) - vi.advanceTimersByTime(10) - }) + await act(async () => fireEvent.click(screen.getByText('Agent'))) expect(mockOnChange).toHaveBeenCalledWith(['agent']) }) @@ -137,10 +97,7 @@ describe('LabelFilter', () => { it('should remove label from selection when clicking already selected label', async () => { render() - await act(async () => { - fireEvent.click(screen.getByText('Agent')) - vi.advanceTimersByTime(10) - }) + await act(async () => fireEvent.click(screen.getByText('Agent'))) // Find the label item in the dropdown list const labelItems = screen.getAllByText('Agent') @@ -149,7 +106,6 @@ describe('LabelFilter', () => { await act(async () => { if (dropdownItem) fireEvent.click(dropdownItem) - vi.advanceTimersByTime(10) }) expect(mockOnChange).toHaveBeenCalledWith([]) @@ -158,17 +114,11 @@ describe('LabelFilter', () => { it('should add label to existing selection', async () => { render() - await act(async () => { - fireEvent.click(screen.getByText('Agent')) - vi.advanceTimersByTime(10) - }) + await act(async () => fireEvent.click(screen.getByText('Agent'))) expect(screen.getByText('RAG')).toBeInTheDocument() - await act(async () => { - fireEvent.click(screen.getByText('RAG')) - vi.advanceTimersByTime(10) - }) + await act(async () => fireEvent.click(screen.getByText('RAG'))) expect(mockOnChange).toHaveBeenCalledWith(['agent', 'rag']) }) @@ -179,8 +129,7 @@ describe('LabelFilter', () => { it('should clear all selections when clear button is clicked', async () => { render() - // Find and click the clear button (XCircle icon's parent) - const clearButton = document.querySelector('.group\\/clear') + const clearButton = screen.getByRole('button', { name: 'common.operation.clear' }) expect(clearButton).toBeInTheDocument() fireEvent.click(clearButton!) @@ -203,21 +152,16 @@ describe('LabelFilter', () => { await act(async () => { fireEvent.click(screen.getByText('common.tag.placeholder')) - vi.advanceTimersByTime(10) }) expect(screen.getByRole('textbox')).toBeInTheDocument() await act(async () => { const searchInput = screen.getByRole('textbox') - // Filter by 'rag' which only matches 'rag' name fireEvent.change(searchInput, { target: { value: 'rag' } }) - vi.advanceTimersByTime(10) }) - // Only RAG should be visible (rag contains 'rag') expect(screen.getByTitle('RAG')).toBeInTheDocument() - // Agent should not be in the dropdown list (agent doesn't contain 'rag') expect(screen.queryByTitle('Agent')).not.toBeInTheDocument() }) @@ -226,7 +170,6 @@ describe('LabelFilter', () => { await act(async () => { fireEvent.click(screen.getByText('common.tag.placeholder')) - vi.advanceTimersByTime(10) }) expect(screen.getByRole('textbox')).toBeInTheDocument() @@ -234,7 +177,6 @@ describe('LabelFilter', () => { await act(async () => { const searchInput = screen.getByRole('textbox') fireEvent.change(searchInput, { target: { value: 'nonexistent' } }) - vi.advanceTimersByTime(10) }) expect(screen.getByText('common.tag.noTag')).toBeInTheDocument() @@ -245,26 +187,21 @@ describe('LabelFilter', () => { await act(async () => { fireEvent.click(screen.getByText('common.tag.placeholder')) - vi.advanceTimersByTime(10) }) expect(screen.getByRole('textbox')).toBeInTheDocument() await act(async () => { const searchInput = screen.getByRole('textbox') - // First filter to show only RAG fireEvent.change(searchInput, { target: { value: 'rag' } }) - vi.advanceTimersByTime(10) }) expect(screen.getByTitle('RAG')).toBeInTheDocument() expect(screen.queryByTitle('Agent')).not.toBeInTheDocument() await act(async () => { - // Clear the input const searchInput = screen.getByRole('textbox') fireEvent.change(searchInput, { target: { value: '' } }) - vi.advanceTimersByTime(10) }) // All labels should be visible again @@ -310,17 +247,11 @@ describe('LabelFilter', () => { it('should call onChange with updated array', async () => { render() - await act(async () => { - fireEvent.click(screen.getByText('common.tag.placeholder')) - vi.advanceTimersByTime(10) - }) + await act(async () => fireEvent.click(screen.getByText('common.tag.placeholder'))) expect(screen.getByText('Agent')).toBeInTheDocument() - await act(async () => { - fireEvent.click(screen.getByText('Agent')) - vi.advanceTimersByTime(10) - }) + await act(async () => fireEvent.click(screen.getByText('Agent'))) expect(mockOnChange).toHaveBeenCalledTimes(1) expect(mockOnChange).toHaveBeenCalledWith(['agent']) diff --git a/web/app/components/tools/labels/filter.tsx b/web/app/components/tools/labels/filter.tsx index 9c1b56d88b..1dadad0b4a 100644 --- a/web/app/components/tools/labels/filter.tsx +++ b/web/app/components/tools/labels/filter.tsx @@ -1,7 +1,6 @@ import type { FC } from 'react' import type { Label } from '@/app/components/tools/labels/constant' import { RiArrowDownSLine } from '@remixicon/react' -import { useDebounceFn } from 'ahooks' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Tag01, Tag03 } from '@/app/components/base/icons/src/vender/line/financeAndECommerce' @@ -9,10 +8,10 @@ import { Check } from '@/app/components/base/icons/src/vender/line/general' import { XCircle } from '@/app/components/base/icons/src/vender/solid/general' import Input from '@/app/components/base/input' import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' + Popover, + PopoverContent, + PopoverTrigger, +} from '@/app/components/base/ui/popover' import { useTags } from '@/app/components/plugins/hooks' import { cn } from '@/utils/classnames' @@ -30,18 +29,10 @@ const LabelFilter: FC = ({ const { tags: labelList } = useTags() const [keywords, setKeywords] = useState('') - const [searchKeywords, setSearchKeywords] = useState('') - const { run: handleSearch } = useDebounceFn(() => { - setSearchKeywords(keywords) - }, { wait: 500 }) - const handleKeywordsChange = (value: string) => { - setKeywords(value) - handleSearch() - } const filteredLabelList = useMemo(() => { - return labelList.filter(label => label.name.includes(searchKeywords)) - }, [labelList, searchKeywords]) + return labelList.filter(label => label.name.includes(keywords)) + }, [labelList, keywords]) const currentLabel = useMemo(() => { return labelList.find(label => label.name === value[0]) @@ -55,72 +46,70 @@ const LabelFilter: FC = ({ } return ( -
    - setOpen(v => !v)} - className="block" - > -
    -
    - -
    -
    - {!value.length && t('tag.placeholder', { ns: 'common' })} - {!!value.length && currentLabel?.label} -
    - {value.length > 1 && ( -
    {`+${value.length - 1}`}
    - )} - {!value.length && ( -
    - -
    - )} - {!!value.length && ( -
    { - e.stopPropagation() - onChange([]) - }} - > - -
    - )} + > +
    +
    - - -
    +
    + {!value.length && t('tag.placeholder', { ns: 'common' })} + {!!value.length && currentLabel?.label} +
    + {value.length > 1 && ( +
    {`+${value.length - 1}`}
    + )} + {!value.length && ( +
    + +
    + )} + + {!!value.length && ( + + )} + +
    handleKeywordsChange(e.target.value)} - onClear={() => handleKeywordsChange('')} + onChange={e => setKeywords(e.target.value)} + onClear={() => setKeywords('')} />
    {filteredLabelList.map(label => ( -
    selectLabel(label)} >
    {label.label}
    {value.includes(label.name) && } -
    + ))} {!filteredLabelList.length && (
    @@ -130,9 +119,9 @@ const LabelFilter: FC = ({ )}
    - +
    - + ) } diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 92774e8d60..1b4b9c2ff8 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -1325,9 +1325,6 @@ } }, "app/components/app/type-selector/index.tsx": { - "no-restricted-imports": { - "count": 1 - }, "tailwindcss/enforce-consistent-class-order": { "count": 3 } @@ -5211,14 +5208,11 @@ } }, "app/components/plugins/plugin-detail-panel/model-selector/tts-params-panel.tsx": { - "no-restricted-imports": { - "count": 1 - }, "tailwindcss/enforce-consistent-class-order": { "count": 2 }, "ts/no-explicit-any": { - "count": 2 + "count": 1 } }, "app/components/plugins/plugin-detail-panel/multiple-tool-selector/index.tsx": { @@ -5975,14 +5969,6 @@ "count": 1 } }, - "app/components/tools/labels/filter.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "tailwindcss/no-unnecessary-whitespace": { - "count": 1 - } - }, "app/components/tools/labels/selector.tsx": { "no-restricted-imports": { "count": 1 From f35a4e5249de4d6ddf15f9bad3bda75ff2cfab08 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:19:37 +0800 Subject: [PATCH 3/5] chore(i18n): sync translations with en-US (#33796) Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- web/i18n/ar-TN/dataset-pipeline.json | 1 + web/i18n/de-DE/dataset-pipeline.json | 1 + web/i18n/es-ES/dataset-pipeline.json | 1 + web/i18n/fa-IR/dataset-pipeline.json | 1 + web/i18n/fr-FR/dataset-pipeline.json | 1 + web/i18n/hi-IN/dataset-pipeline.json | 1 + web/i18n/id-ID/dataset-pipeline.json | 1 + web/i18n/it-IT/dataset-pipeline.json | 1 + web/i18n/ja-JP/dataset-pipeline.json | 1 + web/i18n/ko-KR/dataset-pipeline.json | 1 + web/i18n/nl-NL/dataset-pipeline.json | 1 + web/i18n/pl-PL/dataset-pipeline.json | 1 + web/i18n/pt-BR/dataset-pipeline.json | 1 + web/i18n/ro-RO/dataset-pipeline.json | 1 + web/i18n/ru-RU/dataset-pipeline.json | 1 + web/i18n/sl-SI/dataset-pipeline.json | 1 + web/i18n/th-TH/dataset-pipeline.json | 1 + web/i18n/tr-TR/dataset-pipeline.json | 1 + web/i18n/uk-UA/dataset-pipeline.json | 1 + web/i18n/vi-VN/dataset-pipeline.json | 1 + web/i18n/zh-Hans/dataset-pipeline.json | 1 + web/i18n/zh-Hant/dataset-pipeline.json | 1 + 22 files changed, 22 insertions(+) diff --git a/web/i18n/ar-TN/dataset-pipeline.json b/web/i18n/ar-TN/dataset-pipeline.json index 8ba2615b42..5be245018e 100644 --- a/web/i18n/ar-TN/dataset-pipeline.json +++ b/web/i18n/ar-TN/dataset-pipeline.json @@ -35,6 +35,7 @@ "details.structureTooltip": "يحدد هيكل القطعة كيفية تقسيم المستندات وفهرستها - تقديم أوضاع عامة، الأصل والطفل، والأسئلة والأجوبة - وهي فريدة لكل قاعدة معرفة.", "documentSettings.title": "إعدادات المستند", "editPipelineInfo": "تعديل معلومات سير العمل", + "editPipelineInfoNameRequired": "يرجى إدخال اسم لقاعدة المعرفة.", "exportDSL.errorTip": "فشل تصدير DSL لسير العمل", "exportDSL.successTip": "تم تصدير DSL لسير العمل بنجاح", "inputField": "حقل الإدخال", diff --git a/web/i18n/de-DE/dataset-pipeline.json b/web/i18n/de-DE/dataset-pipeline.json index f71d426686..d6867b2336 100644 --- a/web/i18n/de-DE/dataset-pipeline.json +++ b/web/i18n/de-DE/dataset-pipeline.json @@ -35,6 +35,7 @@ "details.structureTooltip": "Die Blockstruktur bestimmt, wie Dokumente aufgeteilt und indiziert werden, und bietet die Modi \"Allgemein\", \"Über-Eltern-Kind\" und \"Q&A\" und ist für jede Wissensdatenbank einzigartig.", "documentSettings.title": "Dokument-Einstellungen", "editPipelineInfo": "Bearbeiten von Pipeline-Informationen", + "editPipelineInfoNameRequired": "Bitte geben Sie einen Namen für die Wissensdatenbank ein.", "exportDSL.errorTip": "Fehler beim Exportieren der Pipeline-DSL", "exportDSL.successTip": "Pipeline-DSL erfolgreich exportieren", "inputField": "Eingabefeld", diff --git a/web/i18n/es-ES/dataset-pipeline.json b/web/i18n/es-ES/dataset-pipeline.json index 87ca1d3a52..27a4e6adaa 100644 --- a/web/i18n/es-ES/dataset-pipeline.json +++ b/web/i18n/es-ES/dataset-pipeline.json @@ -35,6 +35,7 @@ "details.structureTooltip": "La estructura de fragmentos determina cómo se dividen e indexan los documentos, ofreciendo modos General, Principal-Secundario y Preguntas y respuestas, y es única para cada base de conocimiento.", "documentSettings.title": "Parametrizaciones de documentos", "editPipelineInfo": "Editar información de canalización", + "editPipelineInfoNameRequired": "Por favor, ingrese un nombre para la Base de Conocimiento.", "exportDSL.errorTip": "No se pudo exportar DSL de canalización", "exportDSL.successTip": "Exportar DSL de canalización correctamente", "inputField": "Campo de entrada", diff --git a/web/i18n/fa-IR/dataset-pipeline.json b/web/i18n/fa-IR/dataset-pipeline.json index 6f4d899e6c..a858227339 100644 --- a/web/i18n/fa-IR/dataset-pipeline.json +++ b/web/i18n/fa-IR/dataset-pipeline.json @@ -35,6 +35,7 @@ "details.structureTooltip": "ساختار Chunk نحوه تقسیم و نمایه سازی اسناد را تعیین می کند - حالت های عمومی، والد-فرزند و پرسش و پاسخ را ارائه می دهد - و برای هر پایگاه دانش منحصر به فرد است.", "documentSettings.title": "تنظیمات سند", "editPipelineInfo": "ویرایش اطلاعات خط لوله", + "editPipelineInfoNameRequired": "لطفاً یک نام برای پایگاه دانش وارد کنید.", "exportDSL.errorTip": "صادرات DSL خط لوله انجام نشد", "exportDSL.successTip": "DSL خط لوله را با موفقیت صادر کنید", "inputField": "فیلد ورودی", diff --git a/web/i18n/fr-FR/dataset-pipeline.json b/web/i18n/fr-FR/dataset-pipeline.json index abb0661dd5..46c3ead174 100644 --- a/web/i18n/fr-FR/dataset-pipeline.json +++ b/web/i18n/fr-FR/dataset-pipeline.json @@ -35,6 +35,7 @@ "details.structureTooltip": "La structure par blocs détermine la façon dont les documents sont divisés et indexés (en proposant les modes Général, Parent-Enfant et Q&R) et est unique à chaque base de connaissances.", "documentSettings.title": "Paramètres du document", "editPipelineInfo": "Modifier les informations sur le pipeline", + "editPipelineInfoNameRequired": "Veuillez saisir un nom pour la Base de connaissances.", "exportDSL.errorTip": "Echec de l’exportation du DSL du pipeline", "exportDSL.successTip": "Pipeline d’exportation DSL réussi", "inputField": "Champ de saisie", diff --git a/web/i18n/hi-IN/dataset-pipeline.json b/web/i18n/hi-IN/dataset-pipeline.json index 45a38d08b0..1a8cc033f8 100644 --- a/web/i18n/hi-IN/dataset-pipeline.json +++ b/web/i18n/hi-IN/dataset-pipeline.json @@ -35,6 +35,7 @@ "details.structureTooltip": "चंक संरचना यह निर्धारित करती है कि दस्तावेज कैसे विभाजित और अनुक्रमित होते हैं—सामान्य, माता-पिता- बच्चे, और प्रश्नोत्तर मोड प्रदान करते हुए—और यह प्रत्येक ज्ञान आधार के लिए अद्वितीय होती है।", "documentSettings.title": "डॉक्यूमेंट सेटिंग्स", "editPipelineInfo": "पाइपलाइन जानकारी संपादित करें", + "editPipelineInfoNameRequired": "कृपया ज्ञान आधार के लिए एक नाम दर्ज करें।", "exportDSL.errorTip": "पाइपलाइन DSL निर्यात करने में विफल", "exportDSL.successTip": "निर्यात पाइपलाइन DSL सफलतापूर्वक", "inputField": "इनपुट फ़ील्ड", diff --git a/web/i18n/id-ID/dataset-pipeline.json b/web/i18n/id-ID/dataset-pipeline.json index 8fcaccba4b..a262ba1b12 100644 --- a/web/i18n/id-ID/dataset-pipeline.json +++ b/web/i18n/id-ID/dataset-pipeline.json @@ -35,6 +35,7 @@ "details.structureTooltip": "Struktur Potongan menentukan bagaimana dokumen dibagi dan diindeks—menawarkan mode Umum, Induk-Anak, dan Tanya Jawab—dan unik untuk setiap basis pengetahuan.", "documentSettings.title": "Pengaturan Dokumen", "editPipelineInfo": "Mengedit info alur", + "editPipelineInfoNameRequired": "Silakan masukkan nama untuk Basis Pengetahuan.", "exportDSL.errorTip": "Gagal mengekspor DSL alur", "exportDSL.successTip": "Ekspor DSL pipeline berhasil", "inputField": "Bidang Masukan", diff --git a/web/i18n/it-IT/dataset-pipeline.json b/web/i18n/it-IT/dataset-pipeline.json index 233ca06be1..17a80f05d0 100644 --- a/web/i18n/it-IT/dataset-pipeline.json +++ b/web/i18n/it-IT/dataset-pipeline.json @@ -35,6 +35,7 @@ "details.structureTooltip": "La struttura a blocchi determina il modo in cui i documenti vengono suddivisi e indicizzati, offrendo le modalità Generale, Padre-Figlio e Domande e risposte, ed è univoca per ogni knowledge base.", "documentSettings.title": "Impostazioni documento", "editPipelineInfo": "Modificare le informazioni sulla pipeline", + "editPipelineInfoNameRequired": "Inserisci un nome per la Knowledge Base.", "exportDSL.errorTip": "Impossibile esportare il DSL della pipeline", "exportDSL.successTip": "Esporta DSL pipeline con successo", "inputField": "Campo di input", diff --git a/web/i18n/ja-JP/dataset-pipeline.json b/web/i18n/ja-JP/dataset-pipeline.json index 7d9c1647a8..8cdad967f5 100644 --- a/web/i18n/ja-JP/dataset-pipeline.json +++ b/web/i18n/ja-JP/dataset-pipeline.json @@ -35,6 +35,7 @@ "details.structureTooltip": "チャンク構造は、ドキュメントがどのように分割され、インデックスされるかを決定します。一般、親子、Q&Aモードを提供し、各ナレッジベースにユニークです。", "documentSettings.title": "ドキュメント設定", "editPipelineInfo": "パイプライン情報を編集する", + "editPipelineInfoNameRequired": "ナレッジベースの名前を入力してください。", "exportDSL.errorTip": "パイプラインDSLのエクスポートに失敗しました", "exportDSL.successTip": "エクスポートパイプラインDSLが成功しました", "inputField": "入力フィールド", diff --git a/web/i18n/ko-KR/dataset-pipeline.json b/web/i18n/ko-KR/dataset-pipeline.json index a0da4db0f7..7e6804719c 100644 --- a/web/i18n/ko-KR/dataset-pipeline.json +++ b/web/i18n/ko-KR/dataset-pipeline.json @@ -35,6 +35,7 @@ "details.structureTooltip": "청크 구조는 문서를 분할하고 인덱싱하는 방법(일반, 부모-자식 및 Q&A 모드를 제공)을 결정하며 각 기술 자료에 고유합니다.", "documentSettings.title": "문서 설정", "editPipelineInfo": "파이프라인 정보 편집", + "editPipelineInfoNameRequired": "기술 자료의 이름을 입력해 주세요.", "exportDSL.errorTip": "파이프라인 DSL을 내보내지 못했습니다.", "exportDSL.successTip": "파이프라인 DSL 내보내기 성공", "inputField": "입력 필드", diff --git a/web/i18n/nl-NL/dataset-pipeline.json b/web/i18n/nl-NL/dataset-pipeline.json index 00bd68a519..7f461b48dd 100644 --- a/web/i18n/nl-NL/dataset-pipeline.json +++ b/web/i18n/nl-NL/dataset-pipeline.json @@ -35,6 +35,7 @@ "details.structureTooltip": "Chunk Structure determines how documents are split and indexed—offering General, Parent-Child, and Q&A modes—and is unique to each knowledge base.", "documentSettings.title": "Document Settings", "editPipelineInfo": "Edit pipeline info", + "editPipelineInfoNameRequired": "Voer een naam in voor de Kennisbank.", "exportDSL.errorTip": "Failed to export pipeline DSL", "exportDSL.successTip": "Export pipeline DSL successfully", "inputField": "Input Field", diff --git a/web/i18n/pl-PL/dataset-pipeline.json b/web/i18n/pl-PL/dataset-pipeline.json index 6888e97721..033796cbff 100644 --- a/web/i18n/pl-PL/dataset-pipeline.json +++ b/web/i18n/pl-PL/dataset-pipeline.json @@ -35,6 +35,7 @@ "details.structureTooltip": "Struktura fragmentów określa sposób dzielenia i indeksowania dokumentów — oferując tryby Ogólne, Nadrzędny-Podrzędny oraz Q&A — i jest unikatowa dla każdej bazy wiedzy.", "documentSettings.title": "Ustawienia dokumentu", "editPipelineInfo": "Edytowanie informacji o potoku", + "editPipelineInfoNameRequired": "Proszę podać nazwę Bazy Wiedzy.", "exportDSL.errorTip": "Nie można wyeksportować DSL potoku", "exportDSL.successTip": "Pomyślnie wyeksportowano potok DSL", "inputField": "Pole wejściowe", diff --git a/web/i18n/pt-BR/dataset-pipeline.json b/web/i18n/pt-BR/dataset-pipeline.json index daf25d71e8..8e3ebde859 100644 --- a/web/i18n/pt-BR/dataset-pipeline.json +++ b/web/i18n/pt-BR/dataset-pipeline.json @@ -35,6 +35,7 @@ "details.structureTooltip": "A Estrutura de Partes determina como os documentos são divididos e indexados, oferecendo os modos Geral, Pai-Filho e P e Resposta, e é exclusiva para cada base de conhecimento.", "documentSettings.title": "Configurações do documento", "editPipelineInfo": "Editar informações do pipeline", + "editPipelineInfoNameRequired": "Por favor, insira um nome para a Base de Conhecimento.", "exportDSL.errorTip": "Falha ao exportar DSL de pipeline", "exportDSL.successTip": "Exportar DSL de pipeline com êxito", "inputField": "Campo de entrada", diff --git a/web/i18n/ro-RO/dataset-pipeline.json b/web/i18n/ro-RO/dataset-pipeline.json index 80fc7db0ec..420889e71e 100644 --- a/web/i18n/ro-RO/dataset-pipeline.json +++ b/web/i18n/ro-RO/dataset-pipeline.json @@ -35,6 +35,7 @@ "details.structureTooltip": "Structura de bucăți determină modul în care documentele sunt împărțite și indexate - oferind modurile General, Părinte-Copil și Întrebări și răspunsuri - și este unică pentru fiecare bază de cunoștințe.", "documentSettings.title": "Setări document", "editPipelineInfo": "Editați informațiile despre conductă", + "editPipelineInfoNameRequired": "Vă rugăm să introduceți un nume pentru Baza de Cunoștințe.", "exportDSL.errorTip": "Nu s-a reușit exportul DSL al conductei", "exportDSL.successTip": "Exportați cu succes DSL", "inputField": "Câmp de intrare", diff --git a/web/i18n/ru-RU/dataset-pipeline.json b/web/i18n/ru-RU/dataset-pipeline.json index 4b1f7c20d3..2ec2da0d99 100644 --- a/web/i18n/ru-RU/dataset-pipeline.json +++ b/web/i18n/ru-RU/dataset-pipeline.json @@ -35,6 +35,7 @@ "details.structureTooltip": "Структура блоков определяет порядок разделения и индексирования документов (в соответствии с режимами «Общие», «Родитель-потомок» и «Вопросы и ответы») и является уникальной для каждой базы знаний.", "documentSettings.title": "Настройки документа", "editPipelineInfo": "Редактирование сведений о воронке продаж", + "editPipelineInfoNameRequired": "Пожалуйста, введите название базы знаний.", "exportDSL.errorTip": "Не удалось экспортировать DSL конвейера", "exportDSL.successTip": "Экспорт конвейера DSL успешно", "inputField": "Поле ввода", diff --git a/web/i18n/sl-SI/dataset-pipeline.json b/web/i18n/sl-SI/dataset-pipeline.json index 58464b85fa..c2123636d1 100644 --- a/web/i18n/sl-SI/dataset-pipeline.json +++ b/web/i18n/sl-SI/dataset-pipeline.json @@ -35,6 +35,7 @@ "details.structureTooltip": "Struktura kosov določa, kako so dokumenti razdeljeni in indeksirani – ponuja načine Splošno, Nadrejeno-podrejeno in Vprašanja in odgovori – in je edinstvena za vsako zbirko znanja.", "documentSettings.title": "Nastavitve dokumenta", "editPipelineInfo": "Urejanje informacij o cevovodu", + "editPipelineInfoNameRequired": "Prosim vnesite ime za Bazo znanja.", "exportDSL.errorTip": "Izvoz cevovoda DSL ni uspel", "exportDSL.successTip": "Uspešno izvozite DSL", "inputField": "Vnosno polje", diff --git a/web/i18n/th-TH/dataset-pipeline.json b/web/i18n/th-TH/dataset-pipeline.json index 603d137932..712a5f963d 100644 --- a/web/i18n/th-TH/dataset-pipeline.json +++ b/web/i18n/th-TH/dataset-pipeline.json @@ -35,6 +35,7 @@ "details.structureTooltip": "โครงสร้างก้อนกําหนดวิธีการแยกและจัดทําดัชนีเอกสาร โดยเสนอโหมดทั่วไป ผู้ปกครอง-รอง และ Q&A และไม่ซ้ํากันสําหรับแต่ละฐานความรู้", "documentSettings.title": "การตั้งค่าเอกสาร", "editPipelineInfo": "แก้ไขข้อมูลไปป์ไลน์", + "editPipelineInfoNameRequired": "โปรดป้อนชื่อสำหรับฐานความรู้", "exportDSL.errorTip": "ไม่สามารถส่งออก DSL ไปป์ไลน์ได้", "exportDSL.successTip": "ส่งออก DSL ไปป์ไลน์สําเร็จ", "inputField": "ฟิลด์อินพุต", diff --git a/web/i18n/tr-TR/dataset-pipeline.json b/web/i18n/tr-TR/dataset-pipeline.json index 1979aceced..fe48dcd7bb 100644 --- a/web/i18n/tr-TR/dataset-pipeline.json +++ b/web/i18n/tr-TR/dataset-pipeline.json @@ -35,6 +35,7 @@ "details.structureTooltip": "Yığın Yapısı, belgelerin nasıl bölündüğünü ve dizine eklendiğini belirler (Genel, Üst-Alt ve Soru-Cevap modları sunar) ve her bilgi bankası için benzersizdir.", "documentSettings.title": "Belge Ayarları", "editPipelineInfo": "İşlem hattı bilgilerini düzenleme", + "editPipelineInfoNameRequired": "Lütfen Bilgi Bankası için bir ad girin.", "exportDSL.errorTip": "İşlem hattı DSL'si dışarı aktarılamadı", "exportDSL.successTip": "İşlem hattı DSL'sini başarıyla dışarı aktarın", "inputField": "Giriş Alanı", diff --git a/web/i18n/uk-UA/dataset-pipeline.json b/web/i18n/uk-UA/dataset-pipeline.json index 8df09433f1..fc61912007 100644 --- a/web/i18n/uk-UA/dataset-pipeline.json +++ b/web/i18n/uk-UA/dataset-pipeline.json @@ -35,6 +35,7 @@ "details.structureTooltip": "Структура фрагментів визначає, як документи розділяються та індексуються (пропонуючи режими «Загальні», «Батьки-дочірні елементи» та «Запитання й відповіді»), і є унікальною для кожної бази знань.", "documentSettings.title": "Параметри документа", "editPipelineInfo": "Як редагувати інформацію про воронку продажів", + "editPipelineInfoNameRequired": "Будь ласка, введіть назву Бази знань.", "exportDSL.errorTip": "Не вдалося експортувати DSL пайплайну", "exportDSL.successTip": "Успішний експорт DSL воронки продажів", "inputField": "Поле введення", diff --git a/web/i18n/vi-VN/dataset-pipeline.json b/web/i18n/vi-VN/dataset-pipeline.json index 16ecf7ecc7..8d5ebe11bc 100644 --- a/web/i18n/vi-VN/dataset-pipeline.json +++ b/web/i18n/vi-VN/dataset-pipeline.json @@ -35,6 +35,7 @@ "details.structureTooltip": "Chunk Structure xác định cách các tài liệu được phân tách và lập chỉ mục — cung cấp các chế độ General, Parent-Child và Q&A — và là duy nhất cho mỗi cơ sở tri thức.", "documentSettings.title": "Cài đặt tài liệu", "editPipelineInfo": "Chỉnh sửa thông tin quy trình", + "editPipelineInfoNameRequired": "Vui lòng nhập tên cho Cơ sở Kiến thức.", "exportDSL.errorTip": "Không thể xuất DSL đường ống", "exportDSL.successTip": "Xuất DSL quy trình thành công", "inputField": "Trường đầu vào", diff --git a/web/i18n/zh-Hans/dataset-pipeline.json b/web/i18n/zh-Hans/dataset-pipeline.json index 6819c246a6..e5660da6fd 100644 --- a/web/i18n/zh-Hans/dataset-pipeline.json +++ b/web/i18n/zh-Hans/dataset-pipeline.json @@ -35,6 +35,7 @@ "details.structureTooltip": "文档结构决定了文档的拆分和索引方式,Dify 提供了通用、父子和问答模式,每个知识库的文档结构是唯一的。", "documentSettings.title": "文档设置", "editPipelineInfo": "编辑知识流水线信息", + "editPipelineInfoNameRequired": "请输入知识库的名称。", "exportDSL.errorTip": "导出知识流水线 DSL 失败", "exportDSL.successTip": "成功导出知识流水线 DSL", "inputField": "输入字段", diff --git a/web/i18n/zh-Hant/dataset-pipeline.json b/web/i18n/zh-Hant/dataset-pipeline.json index f2b5c3a6bd..5c56b2fa3f 100644 --- a/web/i18n/zh-Hant/dataset-pipeline.json +++ b/web/i18n/zh-Hant/dataset-pipeline.json @@ -35,6 +35,7 @@ "details.structureTooltip": "區塊結構會決定文件的分割和索引方式 (提供一般、父子和問答模式),而且每個知識庫都是唯一的。", "documentSettings.title": "文件設定", "editPipelineInfo": "編輯管線資訊", + "editPipelineInfoNameRequired": "請輸入知識庫的名稱。", "exportDSL.errorTip": "無法匯出管線 DSL", "exportDSL.successTip": "成功匯出管線 DSL", "inputField": "輸入欄位", From 4d538c3727381f9e607d3e2f298e31427eebcb40 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:29:40 +0800 Subject: [PATCH 4/5] refactor(web): migrate tools/MCP/external-knowledge toast usage to UI toast and add i18n (#33797) --- .../connector/__tests__/index.spec.tsx | 8 +++--- .../connector/index.tsx | 6 +++-- .../__tests__/get-schema.spec.tsx | 13 ++++++---- .../get-schema.tsx | 6 ++--- .../tools/mcp/__tests__/modal.spec.tsx | 21 ++++++++++++++- web/app/components/tools/mcp/modal.tsx | 6 ++--- .../__tests__/custom-create-card.spec.tsx | 10 +++---- .../tools/provider/__tests__/detail.spec.tsx | 5 ++-- .../tools/provider/custom-create-card.tsx | 6 ++--- web/app/components/tools/provider/detail.tsx | 26 +++++++++---------- web/eslint-suppressions.json | 10 ++----- web/i18n/en-US/dataset.json | 2 ++ web/i18n/en-US/tools.json | 2 ++ 13 files changed, 72 insertions(+), 49 deletions(-) diff --git a/web/app/components/datasets/external-knowledge-base/connector/__tests__/index.spec.tsx b/web/app/components/datasets/external-knowledge-base/connector/__tests__/index.spec.tsx index c948450f1b..46235256ce 100644 --- a/web/app/components/datasets/external-knowledge-base/connector/__tests__/index.spec.tsx +++ b/web/app/components/datasets/external-knowledge-base/connector/__tests__/index.spec.tsx @@ -164,7 +164,7 @@ describe('ExternalKnowledgeBaseConnector', () => { // Verify success notification expect(mockNotify).toHaveBeenCalledWith({ type: 'success', - title: 'External Knowledge Base Connected Successfully', + title: 'dataset.externalKnowledgeForm.connectedSuccess', }) // Verify navigation back @@ -206,7 +206,7 @@ describe('ExternalKnowledgeBaseConnector', () => { await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith({ type: 'error', - title: 'Failed to connect External Knowledge Base', + title: 'dataset.externalKnowledgeForm.connectedFailed', }) }) @@ -228,7 +228,7 @@ describe('ExternalKnowledgeBaseConnector', () => { await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith({ type: 'error', - title: 'Failed to connect External Knowledge Base', + title: 'dataset.externalKnowledgeForm.connectedFailed', }) }) @@ -274,7 +274,7 @@ describe('ExternalKnowledgeBaseConnector', () => { await waitFor(() => { expect(mockNotify).toHaveBeenCalledWith({ type: 'success', - title: 'External Knowledge Base Connected Successfully', + title: 'dataset.externalKnowledgeForm.connectedSuccess', }) }) }) diff --git a/web/app/components/datasets/external-knowledge-base/connector/index.tsx b/web/app/components/datasets/external-knowledge-base/connector/index.tsx index 6ff7014f47..adf9be0104 100644 --- a/web/app/components/datasets/external-knowledge-base/connector/index.tsx +++ b/web/app/components/datasets/external-knowledge-base/connector/index.tsx @@ -3,6 +3,7 @@ import type { CreateKnowledgeBaseReq } from '@/app/components/datasets/external-knowledge-base/create/declarations' import * as React from 'react' import { useState } from 'react' +import { useTranslation } from 'react-i18next' import { trackEvent } from '@/app/components/base/amplitude' import { toast } from '@/app/components/base/ui/toast' import ExternalKnowledgeBaseCreate from '@/app/components/datasets/external-knowledge-base/create' @@ -12,13 +13,14 @@ import { createExternalKnowledgeBase } from '@/service/datasets' const ExternalKnowledgeBaseConnector = () => { const [loading, setLoading] = useState(false) const router = useRouter() + const { t } = useTranslation() const handleConnect = async (formValue: CreateKnowledgeBaseReq) => { try { setLoading(true) const result = await createExternalKnowledgeBase({ body: formValue }) if (result && result.id) { - toast.add({ type: 'success', title: 'External Knowledge Base Connected Successfully' }) + toast.add({ type: 'success', title: t('externalKnowledgeForm.connectedSuccess', { ns: 'dataset' }) }) trackEvent('create_external_knowledge_base', { provider: formValue.provider, name: formValue.name, @@ -29,7 +31,7 @@ const ExternalKnowledgeBaseConnector = () => { } catch (error) { console.error('Error creating external knowledge base:', error) - toast.add({ type: 'error', title: 'Failed to connect External Knowledge Base' }) + toast.add({ type: 'error', title: t('externalKnowledgeForm.connectedFailed', { ns: 'dataset' }) }) } setLoading(false) } diff --git a/web/app/components/tools/edit-custom-collection-modal/__tests__/get-schema.spec.tsx b/web/app/components/tools/edit-custom-collection-modal/__tests__/get-schema.spec.tsx index edd2d3dc43..b19a234dc6 100644 --- a/web/app/components/tools/edit-custom-collection-modal/__tests__/get-schema.spec.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/__tests__/get-schema.spec.tsx @@ -1,21 +1,24 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { importSchemaFromURL } from '@/service/tools' -import Toast from '../../../base/toast' import examples from '../examples' import GetSchema from '../get-schema' vi.mock('@/service/tools', () => ({ importSchemaFromURL: vi.fn(), })) +const mockToastAdd = vi.hoisted(() => vi.fn()) +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + add: mockToastAdd, + }, +})) const importSchemaFromURLMock = vi.mocked(importSchemaFromURL) describe('GetSchema', () => { - const notifySpy = vi.spyOn(Toast, 'notify') const mockOnChange = vi.fn() beforeEach(() => { vi.clearAllMocks() - notifySpy.mockClear() importSchemaFromURLMock.mockReset() render() }) @@ -27,9 +30,9 @@ describe('GetSchema', () => { fireEvent.change(input, { target: { value: 'ftp://invalid' } }) fireEvent.click(screen.getByText('common.operation.ok')) - expect(notifySpy).toHaveBeenCalledWith({ + expect(mockToastAdd).toHaveBeenCalledWith({ type: 'error', - message: 'tools.createTool.urlError', + title: 'tools.createTool.urlError', }) }) diff --git a/web/app/components/tools/edit-custom-collection-modal/get-schema.tsx b/web/app/components/tools/edit-custom-collection-modal/get-schema.tsx index 7ad8050a2d..7d34658dec 100644 --- a/web/app/components/tools/edit-custom-collection-modal/get-schema.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/get-schema.tsx @@ -10,8 +10,8 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' +import { toast } from '@/app/components/base/ui/toast' import { importSchemaFromURL } from '@/service/tools' -import Toast from '../../base/toast' import examples from './examples' type Props = { @@ -27,9 +27,9 @@ const GetSchema: FC = ({ const [isParsing, setIsParsing] = useState(false) const handleImportFromUrl = async () => { if (!importUrl.startsWith('http://') && !importUrl.startsWith('https://')) { - Toast.notify({ + toast.add({ type: 'error', - message: t('createTool.urlError', { ns: 'tools' }), + title: t('createTool.urlError', { ns: 'tools' }), }) return } diff --git a/web/app/components/tools/mcp/__tests__/modal.spec.tsx b/web/app/components/tools/mcp/__tests__/modal.spec.tsx index af24ba6061..6b396cae7c 100644 --- a/web/app/components/tools/mcp/__tests__/modal.spec.tsx +++ b/web/app/components/tools/mcp/__tests__/modal.spec.tsx @@ -3,7 +3,7 @@ import type { ToolWithProvider } from '@/app/components/workflow/types' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import MCPModal from '../modal' // Mock the service API @@ -48,7 +48,18 @@ vi.mock('@/service/use-plugins', () => ({ }), })) +const mockToastAdd = vi.hoisted(() => vi.fn()) +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + add: mockToastAdd, + }, +})) + describe('MCPModal', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + const createWrapper = () => { const queryClient = new QueryClient({ defaultOptions: { @@ -299,6 +310,10 @@ describe('MCPModal', () => { // Wait a bit and verify onConfirm was not called await new Promise(resolve => setTimeout(resolve, 100)) expect(onConfirm).not.toHaveBeenCalled() + expect(mockToastAdd).toHaveBeenCalledWith({ + type: 'error', + title: 'tools.mcp.modal.invalidServerUrl', + }) }) it('should not call onConfirm with invalid server identifier', async () => { @@ -320,6 +335,10 @@ describe('MCPModal', () => { // Wait a bit and verify onConfirm was not called await new Promise(resolve => setTimeout(resolve, 100)) expect(onConfirm).not.toHaveBeenCalled() + expect(mockToastAdd).toHaveBeenCalledWith({ + type: 'error', + title: 'tools.mcp.modal.invalidServerIdentifier', + }) }) }) diff --git a/web/app/components/tools/mcp/modal.tsx b/web/app/components/tools/mcp/modal.tsx index 76ba42f2bf..0f21214d34 100644 --- a/web/app/components/tools/mcp/modal.tsx +++ b/web/app/components/tools/mcp/modal.tsx @@ -14,7 +14,7 @@ import { Mcp } from '@/app/components/base/icons/src/vender/other' import Input from '@/app/components/base/input' import Modal from '@/app/components/base/modal' import TabSlider from '@/app/components/base/tab-slider' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' import { MCPAuthMethod } from '@/app/components/tools/types' import { cn } from '@/utils/classnames' import { shouldUseMcpIconForAppIcon } from '@/utils/mcp' @@ -82,11 +82,11 @@ const MCPModalContent: FC = ({ const submit = async () => { if (!isValidUrl(state.url)) { - Toast.notify({ type: 'error', message: 'invalid server url' }) + toast.add({ type: 'error', title: t('mcp.modal.invalidServerUrl', { ns: 'tools' }) }) return } if (!isValidServerID(state.serverIdentifier.trim())) { - Toast.notify({ type: 'error', message: 'invalid server identifier' }) + toast.add({ type: 'error', title: t('mcp.modal.invalidServerIdentifier', { ns: 'tools' }) }) return } const formattedHeaders = state.headers.reduce((acc, item) => { diff --git a/web/app/components/tools/provider/__tests__/custom-create-card.spec.tsx b/web/app/components/tools/provider/__tests__/custom-create-card.spec.tsx index 3643b769f7..63e2531a7f 100644 --- a/web/app/components/tools/provider/__tests__/custom-create-card.spec.tsx +++ b/web/app/components/tools/provider/__tests__/custom-create-card.spec.tsx @@ -70,11 +70,11 @@ vi.mock('@/app/components/tools/edit-custom-collection-modal', () => ({ }, })) -// Mock Toast +// Mock toast const mockToastNotify = vi.fn() -vi.mock('@/app/components/base/toast', () => ({ - default: { - notify: (options: { type: string, message: string }) => mockToastNotify(options), +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { + add: (options: { type: string, title: string }) => mockToastNotify(options), }, })) @@ -200,7 +200,7 @@ describe('CustomCreateCard', () => { await waitFor(() => { expect(mockToastNotify).toHaveBeenCalledWith({ type: 'success', - message: expect.any(String), + title: expect.any(String), }) }) }) diff --git a/web/app/components/tools/provider/__tests__/detail.spec.tsx b/web/app/components/tools/provider/__tests__/detail.spec.tsx index f2d47f8e43..7f8c415c16 100644 --- a/web/app/components/tools/provider/__tests__/detail.spec.tsx +++ b/web/app/components/tools/provider/__tests__/detail.spec.tsx @@ -92,8 +92,9 @@ vi.mock('@/app/components/base/confirm', () => ({ : null, })) -vi.mock('@/app/components/base/toast', () => ({ - default: { notify: vi.fn() }, +const mockToastAdd = vi.hoisted(() => vi.fn()) +vi.mock('@/app/components/base/ui/toast', () => ({ + toast: { add: mockToastAdd }, })) vi.mock('@/app/components/header/indicator', () => ({ diff --git a/web/app/components/tools/provider/custom-create-card.tsx b/web/app/components/tools/provider/custom-create-card.tsx index bf86a1f833..f09d8e45d9 100644 --- a/web/app/components/tools/provider/custom-create-card.tsx +++ b/web/app/components/tools/provider/custom-create-card.tsx @@ -5,7 +5,7 @@ import { } from '@remixicon/react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal' import { useAppContext } from '@/context/app-context' import { createCustomCollection } from '@/service/tools' @@ -21,9 +21,9 @@ const Contribute = ({ onRefreshData }: Props) => { const [isShowEditCollectionToolModal, setIsShowEditCustomCollectionModal] = useState(false) const doCreateCustomToolCollection = async (data: CustomCollectionBackend) => { await createCustomCollection(data) - Toast.notify({ + toast.add({ type: 'success', - message: t('api.actionSuccess', { ns: 'common' }), + title: t('api.actionSuccess', { ns: 'common' }), }) setIsShowEditCustomCollectionModal(false) onRefreshData() diff --git a/web/app/components/tools/provider/detail.tsx b/web/app/components/tools/provider/detail.tsx index e25bcacb9b..626a80a57b 100644 --- a/web/app/components/tools/provider/detail.tsx +++ b/web/app/components/tools/provider/detail.tsx @@ -13,7 +13,7 @@ import Confirm from '@/app/components/base/confirm' import Drawer from '@/app/components/base/drawer' import { LinkExternal02, Settings01 } from '@/app/components/base/icons/src/vender/line/general' import Loading from '@/app/components/base/loading' -import Toast from '@/app/components/base/toast' +import { toast } from '@/app/components/base/ui/toast' import { ConfigurationMethodEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import Indicator from '@/app/components/header/indicator' import Icon from '@/app/components/plugins/card/base/card-icon' @@ -122,18 +122,18 @@ const ProviderDetail = ({ await getCustomProvider() // Use fresh data from form submission to avoid race condition with collection.labels setCustomCollection(prev => prev ? { ...prev, labels: data.labels } : null) - Toast.notify({ + toast.add({ type: 'success', - message: t('api.actionSuccess', { ns: 'common' }), + title: t('api.actionSuccess', { ns: 'common' }), }) setIsShowEditCustomCollectionModal(false) } const doRemoveCustomToolCollection = async () => { await removeCustomCollection(collection?.name as string) onRefreshData() - Toast.notify({ + toast.add({ type: 'success', - message: t('api.actionSuccess', { ns: 'common' }), + title: t('api.actionSuccess', { ns: 'common' }), }) setIsShowEditCustomCollectionModal(false) } @@ -161,9 +161,9 @@ const ProviderDetail = ({ const removeWorkflowToolProvider = async () => { await deleteWorkflowTool(collection.id) onRefreshData() - Toast.notify({ + toast.add({ type: 'success', - message: t('api.actionSuccess', { ns: 'common' }), + title: t('api.actionSuccess', { ns: 'common' }), }) setIsShowEditWorkflowToolModal(false) } @@ -175,9 +175,9 @@ const ProviderDetail = ({ invalidateAllWorkflowTools() onRefreshData() getWorkflowToolProvider() - Toast.notify({ + toast.add({ type: 'success', - message: t('api.actionSuccess', { ns: 'common' }), + title: t('api.actionSuccess', { ns: 'common' }), }) setIsShowEditWorkflowToolModal(false) } @@ -385,18 +385,18 @@ const ProviderDetail = ({ onCancel={() => setShowSettingAuth(false)} onSaved={async (value) => { await updateBuiltInToolCredential(collection.name, value) - Toast.notify({ + toast.add({ type: 'success', - message: t('api.actionSuccess', { ns: 'common' }), + title: t('api.actionSuccess', { ns: 'common' }), }) await onRefreshData() setShowSettingAuth(false) }} onRemove={async () => { await removeBuiltInToolCredential(collection.name) - Toast.notify({ + toast.add({ type: 'success', - message: t('api.actionSuccess', { ns: 'common' }), + title: t('api.actionSuccess', { ns: 'common' }), }) await onRefreshData() setShowSettingAuth(false) diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 1b4b9c2ff8..fb0da9b649 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -5928,9 +5928,6 @@ } }, "app/components/tools/edit-custom-collection-modal/get-schema.tsx": { - "no-restricted-imports": { - "count": 1 - }, "tailwindcss/enforce-consistent-class-order": { "count": 3 }, @@ -6056,7 +6053,7 @@ }, "app/components/tools/mcp/modal.tsx": { "no-restricted-imports": { - "count": 2 + "count": 1 }, "tailwindcss/enforce-consistent-class-order": { "count": 7 @@ -6097,16 +6094,13 @@ } }, "app/components/tools/provider/custom-create-card.tsx": { - "no-restricted-imports": { - "count": 1 - }, "tailwindcss/enforce-consistent-class-order": { "count": 1 } }, "app/components/tools/provider/detail.tsx": { "no-restricted-imports": { - "count": 2 + "count": 1 }, "tailwindcss/enforce-consistent-class-order": { "count": 10 diff --git a/web/i18n/en-US/dataset.json b/web/i18n/en-US/dataset.json index 538517dccd..72d0a7b909 100644 --- a/web/i18n/en-US/dataset.json +++ b/web/i18n/en-US/dataset.json @@ -77,6 +77,8 @@ "externalKnowledgeDescriptionPlaceholder": "Describe what's in this Knowledge Base (optional)", "externalKnowledgeForm.cancel": "Cancel", "externalKnowledgeForm.connect": "Connect", + "externalKnowledgeForm.connectedFailed": "Failed to connect External Knowledge Base", + "externalKnowledgeForm.connectedSuccess": "External Knowledge Base Connected Successfully", "externalKnowledgeId": "External Knowledge ID", "externalKnowledgeIdPlaceholder": "Please enter the Knowledge ID", "externalKnowledgeName": "External Knowledge Name", diff --git a/web/i18n/en-US/tools.json b/web/i18n/en-US/tools.json index 30ee4f58df..391e109317 100644 --- a/web/i18n/en-US/tools.json +++ b/web/i18n/en-US/tools.json @@ -126,6 +126,8 @@ "mcp.modal.headerValuePlaceholder": "e.g., Bearer token123", "mcp.modal.headers": "Headers", "mcp.modal.headersTip": "Additional HTTP headers to send with MCP server requests", + "mcp.modal.invalidServerIdentifier": "Please enter a valid server identifier", + "mcp.modal.invalidServerUrl": "Please enter a valid server URL", "mcp.modal.maskedHeadersTip": "Header values are masked for security. Changes will update the actual values.", "mcp.modal.name": "Name & Icon", "mcp.modal.namePlaceholder": "Name your MCP server", From c8ed584c0e899bc0b1980a269457e1af86f577a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9B=90=E7=B2=92=20Yanli?= Date: Fri, 20 Mar 2026 14:54:23 +0800 Subject: [PATCH 5/5] fix: adding a restore API for version control on workflow draft (#33582) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- api/controllers/console/app/workflow.py | 46 +++- .../rag_pipeline/rag_pipeline_workflow.py | 47 +++- api/models/workflow.py | 116 ++++++++-- api/services/rag_pipeline/rag_pipeline.py | 54 ++++- api/services/workflow_restore.py | 58 +++++ api/services/workflow_service.py | 45 +++- .../services/test_workflow_service.py | 75 +++++++ .../controllers/console/app/test_workflow.py | 130 +++++++++++ .../test_rag_pipeline_workflow.py | 85 ++++++- api/tests/unit_tests/models/test_workflow.py | 38 +++- .../services/test_workflow_service.py | 83 +++++++ .../workflow/test_workflow_restore.py | 77 +++++++ .../plugin-page/__tests__/index.spec.tsx | 18 +- .../components/__tests__/index.spec.tsx | 69 +++++- .../rag-pipeline/components/panel/index.tsx | 1 + .../__tests__/use-nodes-sync-draft.spec.ts | 34 +++ .../use-pipeline-refresh-draft.spec.ts | 26 +++ .../hooks/use-nodes-sync-draft.ts | 7 +- .../hooks/use-pipeline-refresh-draft.ts | 2 + .../components/workflow-panel.tsx | 1 + .../__tests__/use-nodes-sync-draft.spec.ts | 14 ++ .../hooks/use-nodes-sync-draft.ts | 7 +- .../__tests__/header-in-restoring.spec.tsx | 126 +++++++++++ .../workflow/header/header-in-restoring.tsx | 62 +++--- .../components/workflow/hooks-store/store.ts | 11 +- .../workflow/hooks/use-nodes-sync-draft.ts | 9 +- .../workflow/panel/__tests__/index.spec.tsx | 115 ++++++++++ web/app/components/workflow/panel/index.tsx | 2 +- .../__tests__/index.spec.tsx | 209 +++++++++++++----- .../panel/version-history-panel/index.tsx | 57 ++--- web/service/use-workflow.ts | 7 + 31 files changed, 1452 insertions(+), 179 deletions(-) create mode 100644 api/services/workflow_restore.py create mode 100644 api/tests/unit_tests/services/workflow/test_workflow_restore.py create mode 100644 web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx create mode 100644 web/app/components/workflow/panel/__tests__/index.spec.tsx diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 837245ecb1..d59aa44718 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -7,7 +7,7 @@ from flask import abort, request from flask_restx import Resource, fields, marshal_with from pydantic import BaseModel, Field, field_validator from sqlalchemy.orm import Session -from werkzeug.exceptions import Forbidden, InternalServerError, NotFound +from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound import services from controllers.console import console_ns @@ -46,13 +46,14 @@ from models import App from models.model import AppMode from models.workflow import Workflow from services.app_generate_service import AppGenerateService -from services.errors.app import WorkflowHashNotEqualError +from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError from services.errors.llm import InvokeRateLimitError from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService logger = logging.getLogger(__name__) LISTENING_RETRY_IN = 2000 DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" +RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE = "source workflow must be published" # Register models for flask_restx to avoid dict type issues in Swagger # Register in dependency order: base models first, then dependent models @@ -284,7 +285,9 @@ class DraftWorkflowApi(Resource): workflow_service = WorkflowService() try: - environment_variables_list = args.get("environment_variables") or [] + environment_variables_list = Workflow.normalize_environment_variable_mappings( + args.get("environment_variables") or [], + ) environment_variables = [ variable_factory.build_environment_variable_from_mapping(obj) for obj in environment_variables_list ] @@ -994,6 +997,43 @@ class PublishedAllWorkflowApi(Resource): } +@console_ns.route("/apps//workflows//restore") +class DraftWorkflowRestoreApi(Resource): + @console_ns.doc("restore_workflow_to_draft") + @console_ns.doc(description="Restore a published workflow version into the draft workflow") + @console_ns.doc(params={"app_id": "Application ID", "workflow_id": "Published workflow ID"}) + @console_ns.response(200, "Workflow restored successfully") + @console_ns.response(400, "Source workflow must be published") + @console_ns.response(404, "Workflow not found") + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + @edit_permission_required + def post(self, app_model: App, workflow_id: str): + current_user, _ = current_account_with_tenant() + workflow_service = WorkflowService() + + try: + workflow = workflow_service.restore_published_workflow_to_draft( + app_model=app_model, + workflow_id=workflow_id, + account=current_user, + ) + except IsDraftWorkflowError as exc: + raise BadRequest(RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE) from exc + except WorkflowNotFoundError as exc: + raise NotFound(str(exc)) from exc + except ValueError as exc: + raise BadRequest(str(exc)) from exc + + return { + "result": "success", + "hash": workflow.unique_hash, + "updated_at": TimestampField().format(workflow.updated_at or workflow.created_at), + } + + @console_ns.route("/apps//workflows/") class WorkflowByIdApi(Resource): @console_ns.doc("update_workflow_by_id") diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py index 51cdcc0c7a..3912cc73ca 100644 --- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py +++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline_workflow.py @@ -6,7 +6,7 @@ from flask import abort, request from flask_restx import Resource, marshal_with # type: ignore from pydantic import BaseModel, Field from sqlalchemy.orm import Session -from werkzeug.exceptions import Forbidden, InternalServerError, NotFound +from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound import services from controllers.common.schema import register_schema_models @@ -16,7 +16,11 @@ from controllers.console.app.error import ( DraftWorkflowNotExist, DraftWorkflowNotSync, ) -from controllers.console.app.workflow import workflow_model, workflow_pagination_model +from controllers.console.app.workflow import ( + RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE, + workflow_model, + workflow_pagination_model, +) from controllers.console.app.workflow_run import ( workflow_run_detail_model, workflow_run_node_execution_list_model, @@ -42,7 +46,8 @@ from libs.login import current_account_with_tenant, current_user, login_required from models import Account from models.dataset import Pipeline from models.model import EndUser -from services.errors.app import WorkflowHashNotEqualError +from models.workflow import Workflow +from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError from services.errors.llm import InvokeRateLimitError from services.rag_pipeline.pipeline_generate_service import PipelineGenerateService from services.rag_pipeline.rag_pipeline import RagPipelineService @@ -203,9 +208,12 @@ class DraftRagPipelineApi(Resource): abort(415) payload = DraftWorkflowSyncPayload.model_validate(payload_dict) + rag_pipeline_service = RagPipelineService() try: - environment_variables_list = payload.environment_variables or [] + environment_variables_list = Workflow.normalize_environment_variable_mappings( + payload.environment_variables or [], + ) environment_variables = [ variable_factory.build_environment_variable_from_mapping(obj) for obj in environment_variables_list ] @@ -213,7 +221,6 @@ class DraftRagPipelineApi(Resource): conversation_variables = [ variable_factory.build_conversation_variable_from_mapping(obj) for obj in conversation_variables_list ] - rag_pipeline_service = RagPipelineService() workflow = rag_pipeline_service.sync_draft_workflow( pipeline=pipeline, graph=payload.graph, @@ -705,6 +712,36 @@ class PublishedAllRagPipelineApi(Resource): } +@console_ns.route("/rag/pipelines//workflows//restore") +class RagPipelineDraftWorkflowRestoreApi(Resource): + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + @get_rag_pipeline + def post(self, pipeline: Pipeline, workflow_id: str): + current_user, _ = current_account_with_tenant() + rag_pipeline_service = RagPipelineService() + + try: + workflow = rag_pipeline_service.restore_published_workflow_to_draft( + pipeline=pipeline, + workflow_id=workflow_id, + account=current_user, + ) + except IsDraftWorkflowError as exc: + # Use a stable, predefined message to keep the 400 response consistent + raise BadRequest(RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE) from exc + except WorkflowNotFoundError as exc: + raise NotFound(str(exc)) from exc + + return { + "result": "success", + "hash": workflow.unique_hash, + "updated_at": TimestampField().format(workflow.updated_at or workflow.created_at), + } + + @console_ns.route("/rag/pipelines//workflows/") class RagPipelineByIdApi(Resource): @setup_required diff --git a/api/models/workflow.py b/api/models/workflow.py index e7b20d0e65..6e8dda429d 100644 --- a/api/models/workflow.py +++ b/api/models/workflow.py @@ -1,3 +1,4 @@ +import copy import json import logging from collections.abc import Generator, Mapping, Sequence @@ -302,26 +303,40 @@ class Workflow(Base): # bug def features(self) -> str: """ Convert old features structure to new features structure. + + This property avoids rewriting the underlying JSON when normalization + produces no effective change, to prevent marking the row dirty on read. """ if not self._features: return self._features - features = json.loads(self._features) - if features.get("file_upload", {}).get("image", {}).get("enabled", False): - image_enabled = True - image_number_limits = int(features["file_upload"]["image"].get("number_limits", DEFAULT_FILE_NUMBER_LIMITS)) - image_transfer_methods = features["file_upload"]["image"].get( - "transfer_methods", ["remote_url", "local_file"] - ) - features["file_upload"]["enabled"] = image_enabled - features["file_upload"]["number_limits"] = image_number_limits - features["file_upload"]["allowed_file_upload_methods"] = image_transfer_methods - features["file_upload"]["allowed_file_types"] = features["file_upload"].get("allowed_file_types", ["image"]) - features["file_upload"]["allowed_file_extensions"] = features["file_upload"].get( - "allowed_file_extensions", [] - ) - del features["file_upload"]["image"] - self._features = json.dumps(features) + # Parse once and deep-copy before normalization to detect in-place changes. + original_dict = self._decode_features_payload(self._features) + if original_dict is None: + return self._features + + # Fast-path: if the legacy file_upload.image.enabled shape is absent, skip + # deep-copy and normalization entirely and return the stored JSON. + file_upload_payload = original_dict.get("file_upload") + if not isinstance(file_upload_payload, dict): + return self._features + file_upload = cast(dict[str, Any], file_upload_payload) + + image_payload = file_upload.get("image") + if not isinstance(image_payload, dict): + return self._features + image = cast(dict[str, Any], image_payload) + if "enabled" not in image: + return self._features + + normalized_dict = self._normalize_features_payload(copy.deepcopy(original_dict)) + + if normalized_dict == original_dict: + # No effective change; return stored JSON unchanged. + return self._features + + # Normalization changed the payload: persist the normalized JSON. + self._features = json.dumps(normalized_dict) return self._features @features.setter @@ -332,6 +347,44 @@ class Workflow(Base): # bug def features_dict(self) -> dict[str, Any]: return json.loads(self.features) if self.features else {} + @property + def serialized_features(self) -> str: + """Return the stored features JSON without triggering compatibility rewrites.""" + return self._features + + @property + def normalized_features_dict(self) -> dict[str, Any]: + """Decode features with legacy normalization without mutating the model state.""" + if not self._features: + return {} + + features = self._decode_features_payload(self._features) + return self._normalize_features_payload(features) if features is not None else {} + + @staticmethod + def _decode_features_payload(features: str) -> dict[str, Any] | None: + """Decode workflow features JSON when it contains an object payload.""" + payload = json.loads(features) + return cast(dict[str, Any], payload) if isinstance(payload, dict) else None + + @staticmethod + def _normalize_features_payload(features: dict[str, Any]) -> dict[str, Any]: + if features.get("file_upload", {}).get("image", {}).get("enabled", False): + image_number_limits = int(features["file_upload"]["image"].get("number_limits", DEFAULT_FILE_NUMBER_LIMITS)) + image_transfer_methods = features["file_upload"]["image"].get( + "transfer_methods", ["remote_url", "local_file"] + ) + features["file_upload"]["enabled"] = True + features["file_upload"]["number_limits"] = image_number_limits + features["file_upload"]["allowed_file_upload_methods"] = image_transfer_methods + features["file_upload"]["allowed_file_types"] = features["file_upload"].get("allowed_file_types", ["image"]) + features["file_upload"]["allowed_file_extensions"] = features["file_upload"].get( + "allowed_file_extensions", [] + ) + del features["file_upload"]["image"] + + return features + def walk_nodes( self, specific_node_type: NodeType | None = None ) -> Generator[tuple[str, Mapping[str, Any]], None, None]: @@ -517,6 +570,31 @@ class Workflow(Base): # bug ) self._environment_variables = environment_variables_json + @staticmethod + def normalize_environment_variable_mappings( + mappings: Sequence[Mapping[str, Any]], + ) -> list[dict[str, Any]]: + """Convert masked secret placeholders into the draft hidden sentinel. + + Regular draft sync requests should preserve existing secrets without shipping + plaintext values back from the client. The dedicated restore endpoint now + copies published secrets server-side, so draft sync only needs to normalize + the UI mask into `HIDDEN_VALUE`. + """ + masked_secret_value = encrypter.full_mask_token() + normalized_mappings: list[dict[str, Any]] = [] + + for mapping in mappings: + normalized_mapping = dict(mapping) + if ( + normalized_mapping.get("value_type") == SegmentType.SECRET.value + and normalized_mapping.get("value") == masked_secret_value + ): + normalized_mapping["value"] = HIDDEN_VALUE + normalized_mappings.append(normalized_mapping) + + return normalized_mappings + def to_dict(self, *, include_secret: bool = False) -> WorkflowContentDict: environment_variables = list(self.environment_variables) environment_variables = [ @@ -564,6 +642,12 @@ class Workflow(Base): # bug ensure_ascii=False, ) + def copy_serialized_variable_storage_from(self, source_workflow: "Workflow") -> None: + """Copy stored variable JSON directly for same-tenant restore flows.""" + self._environment_variables = source_workflow._environment_variables + self._conversation_variables = source_workflow._conversation_variables + self._rag_pipeline_variables = source_workflow._rag_pipeline_variables + @staticmethod def version_from_datetime(d: datetime) -> str: return str(d) diff --git a/api/services/rag_pipeline/rag_pipeline.py b/api/services/rag_pipeline/rag_pipeline.py index f3aedafac9..296b9f0890 100644 --- a/api/services/rag_pipeline/rag_pipeline.py +++ b/api/services/rag_pipeline/rag_pipeline.py @@ -79,10 +79,11 @@ from services.entities.knowledge_entities.rag_pipeline_entities import ( KnowledgeConfiguration, PipelineTemplateInfoEntity, ) -from services.errors.app import WorkflowHashNotEqualError +from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError from services.rag_pipeline.pipeline_template.pipeline_template_factory import PipelineTemplateRetrievalFactory from services.tools.builtin_tools_manage_service import BuiltinToolManageService from services.workflow_draft_variable_service import DraftVariableSaver, DraftVarLoader +from services.workflow_restore import apply_published_workflow_snapshot_to_draft logger = logging.getLogger(__name__) @@ -234,6 +235,21 @@ class RagPipelineService: return workflow + def get_published_workflow_by_id(self, pipeline: Pipeline, workflow_id: str) -> Workflow | None: + """Fetch a published workflow snapshot by ID for restore operations.""" + workflow = ( + db.session.query(Workflow) + .where( + Workflow.tenant_id == pipeline.tenant_id, + Workflow.app_id == pipeline.id, + Workflow.id == workflow_id, + ) + .first() + ) + if workflow and workflow.version == Workflow.VERSION_DRAFT: + raise IsDraftWorkflowError("source workflow must be published") + return workflow + def get_all_published_workflow( self, *, @@ -327,6 +343,42 @@ class RagPipelineService: # return draft workflow return workflow + def restore_published_workflow_to_draft( + self, + *, + pipeline: Pipeline, + workflow_id: str, + account: Account, + ) -> Workflow: + """Restore a published pipeline workflow snapshot into the draft workflow. + + Pipelines reuse the shared draft-restore field copy helper, but still own + the pipeline-specific flush/link step that wires a newly created draft + back onto ``pipeline.workflow_id``. + """ + source_workflow = self.get_published_workflow_by_id(pipeline=pipeline, workflow_id=workflow_id) + if not source_workflow: + raise WorkflowNotFoundError("Workflow not found.") + + draft_workflow = self.get_draft_workflow(pipeline=pipeline) + draft_workflow, is_new_draft = apply_published_workflow_snapshot_to_draft( + tenant_id=pipeline.tenant_id, + app_id=pipeline.id, + source_workflow=source_workflow, + draft_workflow=draft_workflow, + account=account, + updated_at_factory=lambda: datetime.now(UTC).replace(tzinfo=None), + ) + + if is_new_draft: + db.session.add(draft_workflow) + db.session.flush() + pipeline.workflow_id = draft_workflow.id + + db.session.commit() + + return draft_workflow + def publish_workflow( self, *, diff --git a/api/services/workflow_restore.py b/api/services/workflow_restore.py new file mode 100644 index 0000000000..083235d228 --- /dev/null +++ b/api/services/workflow_restore.py @@ -0,0 +1,58 @@ +"""Shared helpers for restoring published workflow snapshots into drafts. + +Both app workflows and RAG pipeline workflows restore the same workflow fields +from a published snapshot into a draft. Keeping that field-copy logic in one +place prevents the two restore paths from drifting when we add or adjust draft +state in the future. Restore stays within a tenant, so we can safely reuse the +serialized workflow storage blobs without decrypting and re-encrypting secrets. +""" + +from collections.abc import Callable +from datetime import datetime + +from models import Account +from models.workflow import Workflow, WorkflowType + +UpdatedAtFactory = Callable[[], datetime] + + +def apply_published_workflow_snapshot_to_draft( + *, + tenant_id: str, + app_id: str, + source_workflow: Workflow, + draft_workflow: Workflow | None, + account: Account, + updated_at_factory: UpdatedAtFactory, +) -> tuple[Workflow, bool]: + """Copy a published workflow snapshot into a draft workflow record. + + The caller remains responsible for source lookup, validation, flushing, and + post-commit side effects. This helper only centralizes the shared draft + creation/update semantics used by both restore entry points. Features are + copied from the stored JSON payload so restore does not normalize and dirty + the published source row before the caller commits. + """ + if not draft_workflow: + workflow_type = ( + source_workflow.type.value if isinstance(source_workflow.type, WorkflowType) else source_workflow.type + ) + draft_workflow = Workflow( + tenant_id=tenant_id, + app_id=app_id, + type=workflow_type, + version=Workflow.VERSION_DRAFT, + graph=source_workflow.graph, + features=source_workflow.serialized_features, + created_by=account.id, + ) + draft_workflow.copy_serialized_variable_storage_from(source_workflow) + return draft_workflow, True + + draft_workflow.graph = source_workflow.graph + draft_workflow.features = source_workflow.serialized_features + draft_workflow.updated_by = account.id + draft_workflow.updated_at = updated_at_factory() + draft_workflow.copy_serialized_variable_storage_from(source_workflow) + + return draft_workflow, False diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index e13cdd5f27..66976058c0 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -63,7 +63,12 @@ from models.workflow import Workflow, WorkflowNodeExecutionModel, WorkflowNodeEx from repositories.factory import DifyAPIRepositoryFactory from services.billing_service import BillingService from services.enterprise.plugin_manager_service import PluginCredentialType -from services.errors.app import IsDraftWorkflowError, TriggerNodeLimitExceededError, WorkflowHashNotEqualError +from services.errors.app import ( + IsDraftWorkflowError, + TriggerNodeLimitExceededError, + WorkflowHashNotEqualError, + WorkflowNotFoundError, +) from services.workflow.workflow_converter import WorkflowConverter from .errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError @@ -75,6 +80,7 @@ from .human_input_delivery_test_service import ( HumanInputDeliveryTestService, ) from .workflow_draft_variable_service import DraftVariableSaver, DraftVarLoader, WorkflowDraftVariableService +from .workflow_restore import apply_published_workflow_snapshot_to_draft class WorkflowService: @@ -279,6 +285,43 @@ class WorkflowService: # return draft workflow return workflow + def restore_published_workflow_to_draft( + self, + *, + app_model: App, + workflow_id: str, + account: Account, + ) -> Workflow: + """Restore a published workflow snapshot into the draft workflow. + + Secret environment variables are copied server-side from the selected + published workflow so the normal draft sync flow stays stateless. + """ + source_workflow = self.get_published_workflow_by_id(app_model=app_model, workflow_id=workflow_id) + if not source_workflow: + raise WorkflowNotFoundError("Workflow not found.") + + self.validate_features_structure(app_model=app_model, features=source_workflow.normalized_features_dict) + self.validate_graph_structure(graph=source_workflow.graph_dict) + + draft_workflow = self.get_draft_workflow(app_model=app_model) + draft_workflow, is_new_draft = apply_published_workflow_snapshot_to_draft( + tenant_id=app_model.tenant_id, + app_id=app_model.id, + source_workflow=source_workflow, + draft_workflow=draft_workflow, + account=account, + updated_at_factory=naive_utc_now, + ) + + if is_new_draft: + db.session.add(draft_workflow) + + db.session.commit() + app_draft_workflow_was_synced.send(app_model, synced_draft_workflow=draft_workflow) + + return draft_workflow + def publish_workflow( self, *, diff --git a/api/tests/test_containers_integration_tests/services/test_workflow_service.py b/api/tests/test_containers_integration_tests/services/test_workflow_service.py index 056db41750..a5fe052206 100644 --- a/api/tests/test_containers_integration_tests/services/test_workflow_service.py +++ b/api/tests/test_containers_integration_tests/services/test_workflow_service.py @@ -802,6 +802,81 @@ class TestWorkflowService: with pytest.raises(ValueError, match="No valid workflow found"): workflow_service.publish_workflow(session=db_session_with_containers, app_model=app, account=account) + def test_restore_published_workflow_to_draft_does_not_persist_normalized_source_features( + self, db_session_with_containers: Session + ): + """Restore copies legacy feature JSON into draft without rewriting the source row.""" + fake = Faker() + account = self._create_test_account(db_session_with_containers, fake) + app = self._create_test_app(db_session_with_containers, fake) + app.mode = AppMode.ADVANCED_CHAT + + legacy_features = { + "file_upload": { + "image": { + "enabled": True, + "number_limits": 6, + "transfer_methods": ["remote_url", "local_file"], + } + }, + "opening_statement": "", + "retriever_resource": {"enabled": True}, + "sensitive_word_avoidance": {"enabled": False}, + "speech_to_text": {"enabled": False}, + "suggested_questions": [], + "suggested_questions_after_answer": {"enabled": False}, + "text_to_speech": {"enabled": False, "language": "", "voice": ""}, + } + published_workflow = Workflow( + id=fake.uuid4(), + tenant_id=app.tenant_id, + app_id=app.id, + type=WorkflowType.WORKFLOW, + version="2026.03.19.001", + graph=json.dumps({"nodes": [], "edges": []}), + features=json.dumps(legacy_features), + created_by=account.id, + updated_by=account.id, + environment_variables=[], + conversation_variables=[], + ) + draft_workflow = Workflow( + id=fake.uuid4(), + tenant_id=app.tenant_id, + app_id=app.id, + type=WorkflowType.WORKFLOW, + version=Workflow.VERSION_DRAFT, + graph=json.dumps({"nodes": [], "edges": []}), + features=json.dumps({}), + created_by=account.id, + updated_by=account.id, + environment_variables=[], + conversation_variables=[], + ) + db_session_with_containers.add(published_workflow) + db_session_with_containers.add(draft_workflow) + db_session_with_containers.commit() + + workflow_service = WorkflowService() + + restored_workflow = workflow_service.restore_published_workflow_to_draft( + app_model=app, + workflow_id=published_workflow.id, + account=account, + ) + + db_session_with_containers.expire_all() + refreshed_published_workflow = ( + db_session_with_containers.query(Workflow).filter_by(id=published_workflow.id).first() + ) + refreshed_draft_workflow = db_session_with_containers.query(Workflow).filter_by(id=draft_workflow.id).first() + + assert restored_workflow.id == draft_workflow.id + assert refreshed_published_workflow is not None + assert refreshed_draft_workflow is not None + assert refreshed_published_workflow.serialized_features == json.dumps(legacy_features) + assert refreshed_draft_workflow.serialized_features == json.dumps(legacy_features) + def test_get_default_block_configs(self, db_session_with_containers: Session): """ Test retrieval of default block configurations for all node types. diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow.py b/api/tests/unit_tests/controllers/console/app/test_workflow.py index f100080eaa..0e22db9f9b 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow.py @@ -129,6 +129,136 @@ def test_sync_draft_workflow_hash_mismatch(app, monkeypatch: pytest.MonkeyPatch) handler(api, app_model=SimpleNamespace(id="app")) +def test_restore_published_workflow_to_draft_success(app, monkeypatch: pytest.MonkeyPatch) -> None: + workflow = SimpleNamespace( + unique_hash="restored-hash", + updated_at=None, + created_at=datetime(2024, 1, 1), + ) + user = SimpleNamespace(id="account-1") + + monkeypatch.setattr(workflow_module, "current_account_with_tenant", lambda: (user, "t1")) + monkeypatch.setattr( + workflow_module, + "WorkflowService", + lambda: SimpleNamespace(restore_published_workflow_to_draft=lambda **_kwargs: workflow), + ) + + api = workflow_module.DraftWorkflowRestoreApi() + handler = _unwrap(api.post) + + with app.test_request_context( + "/apps/app/workflows/published-workflow/restore", + method="POST", + ): + response = handler( + api, + app_model=SimpleNamespace(id="app", tenant_id="tenant-1"), + workflow_id="published-workflow", + ) + + assert response["result"] == "success" + assert response["hash"] == "restored-hash" + + +def test_restore_published_workflow_to_draft_not_found(app, monkeypatch: pytest.MonkeyPatch) -> None: + user = SimpleNamespace(id="account-1") + + monkeypatch.setattr(workflow_module, "current_account_with_tenant", lambda: (user, "t1")) + monkeypatch.setattr( + workflow_module, + "WorkflowService", + lambda: SimpleNamespace( + restore_published_workflow_to_draft=lambda **_kwargs: (_ for _ in ()).throw( + workflow_module.WorkflowNotFoundError("Workflow not found") + ) + ), + ) + + api = workflow_module.DraftWorkflowRestoreApi() + handler = _unwrap(api.post) + + with app.test_request_context( + "/apps/app/workflows/published-workflow/restore", + method="POST", + ): + with pytest.raises(NotFound): + handler( + api, + app_model=SimpleNamespace(id="app", tenant_id="tenant-1"), + workflow_id="published-workflow", + ) + + +def test_restore_published_workflow_to_draft_returns_400_for_draft_source(app, monkeypatch: pytest.MonkeyPatch) -> None: + user = SimpleNamespace(id="account-1") + + monkeypatch.setattr(workflow_module, "current_account_with_tenant", lambda: (user, "t1")) + monkeypatch.setattr( + workflow_module, + "WorkflowService", + lambda: SimpleNamespace( + restore_published_workflow_to_draft=lambda **_kwargs: (_ for _ in ()).throw( + workflow_module.IsDraftWorkflowError( + "Cannot use draft workflow version. Workflow ID: draft-workflow. " + "Please use a published workflow version or leave workflow_id empty." + ) + ) + ), + ) + + api = workflow_module.DraftWorkflowRestoreApi() + handler = _unwrap(api.post) + + with app.test_request_context( + "/apps/app/workflows/draft-workflow/restore", + method="POST", + ): + with pytest.raises(HTTPException) as exc: + handler( + api, + app_model=SimpleNamespace(id="app", tenant_id="tenant-1"), + workflow_id="draft-workflow", + ) + + assert exc.value.code == 400 + assert exc.value.description == workflow_module.RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE + + +def test_restore_published_workflow_to_draft_returns_400_for_invalid_structure( + app, monkeypatch: pytest.MonkeyPatch +) -> None: + user = SimpleNamespace(id="account-1") + + monkeypatch.setattr(workflow_module, "current_account_with_tenant", lambda: (user, "t1")) + monkeypatch.setattr( + workflow_module, + "WorkflowService", + lambda: SimpleNamespace( + restore_published_workflow_to_draft=lambda **_kwargs: (_ for _ in ()).throw( + ValueError("invalid workflow graph") + ) + ), + ) + + api = workflow_module.DraftWorkflowRestoreApi() + handler = _unwrap(api.post) + + with app.test_request_context( + "/apps/app/workflows/published-workflow/restore", + method="POST", + ): + with pytest.raises(HTTPException) as exc: + handler( + api, + app_model=SimpleNamespace(id="app", tenant_id="tenant-1"), + workflow_id="published-workflow", + ) + + assert exc.value.code == 400 + assert exc.value.description == "invalid workflow graph" + + def test_draft_workflow_get_not_found(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( workflow_module, "WorkflowService", lambda: SimpleNamespace(get_draft_workflow=lambda **_k: None) diff --git a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py index 7775cbdd81..472d133349 100644 --- a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py +++ b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline_workflow.py @@ -2,7 +2,7 @@ from datetime import datetime from unittest.mock import MagicMock, patch import pytest -from werkzeug.exceptions import Forbidden, NotFound +from werkzeug.exceptions import Forbidden, HTTPException, NotFound import services from controllers.console import console_ns @@ -19,13 +19,14 @@ from controllers.console.datasets.rag_pipeline.rag_pipeline_workflow import ( RagPipelineDraftNodeRunApi, RagPipelineDraftRunIterationNodeApi, RagPipelineDraftRunLoopNodeApi, + RagPipelineDraftWorkflowRestoreApi, RagPipelineRecommendedPluginApi, RagPipelineTaskStopApi, RagPipelineTransformApi, RagPipelineWorkflowLastRunApi, ) from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError -from services.errors.app import WorkflowHashNotEqualError +from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError from services.errors.llm import InvokeRateLimitError @@ -116,6 +117,86 @@ class TestDraftWorkflowApi: response, status = method(api, pipeline) assert status == 400 + def test_restore_published_workflow_to_draft_success(self, app): + api = RagPipelineDraftWorkflowRestoreApi() + method = unwrap(api.post) + + pipeline = MagicMock() + user = MagicMock(id="account-1") + workflow = MagicMock(unique_hash="restored-hash", updated_at=None, created_at=datetime(2024, 1, 1)) + + service = MagicMock() + service.restore_published_workflow_to_draft.return_value = workflow + + with ( + app.test_request_context("/", method="POST"), + patch( + "controllers.console.datasets.rag_pipeline.rag_pipeline_workflow.current_account_with_tenant", + return_value=(user, "t"), + ), + patch( + "controllers.console.datasets.rag_pipeline.rag_pipeline_workflow.RagPipelineService", + return_value=service, + ), + ): + result = method(api, pipeline, "published-workflow") + + assert result["result"] == "success" + assert result["hash"] == "restored-hash" + + def test_restore_published_workflow_to_draft_not_found(self, app): + api = RagPipelineDraftWorkflowRestoreApi() + method = unwrap(api.post) + + pipeline = MagicMock() + user = MagicMock(id="account-1") + + service = MagicMock() + service.restore_published_workflow_to_draft.side_effect = WorkflowNotFoundError("Workflow not found") + + with ( + app.test_request_context("/", method="POST"), + patch( + "controllers.console.datasets.rag_pipeline.rag_pipeline_workflow.current_account_with_tenant", + return_value=(user, "t"), + ), + patch( + "controllers.console.datasets.rag_pipeline.rag_pipeline_workflow.RagPipelineService", + return_value=service, + ), + ): + with pytest.raises(NotFound): + method(api, pipeline, "published-workflow") + + def test_restore_published_workflow_to_draft_returns_400_for_draft_source(self, app): + api = RagPipelineDraftWorkflowRestoreApi() + method = unwrap(api.post) + + pipeline = MagicMock() + user = MagicMock(id="account-1") + + service = MagicMock() + service.restore_published_workflow_to_draft.side_effect = IsDraftWorkflowError( + "source workflow must be published" + ) + + with ( + app.test_request_context("/", method="POST"), + patch( + "controllers.console.datasets.rag_pipeline.rag_pipeline_workflow.current_account_with_tenant", + return_value=(user, "t"), + ), + patch( + "controllers.console.datasets.rag_pipeline.rag_pipeline_workflow.RagPipelineService", + return_value=service, + ), + ): + with pytest.raises(HTTPException) as exc: + method(api, pipeline, "draft-workflow") + + assert exc.value.code == 400 + assert exc.value.description == "source workflow must be published" + class TestDraftRunNodes: def test_iteration_node_success(self, app): diff --git a/api/tests/unit_tests/models/test_workflow.py b/api/tests/unit_tests/models/test_workflow.py index f3b72aa128..ef29b26a7a 100644 --- a/api/tests/unit_tests/models/test_workflow.py +++ b/api/tests/unit_tests/models/test_workflow.py @@ -4,12 +4,18 @@ from unittest import mock from uuid import uuid4 from constants import HIDDEN_VALUE +from core.helper import encrypter from dify_graph.file.enums import FileTransferMethod, FileType from dify_graph.file.models import File from dify_graph.variables import FloatVariable, IntegerVariable, SecretVariable, StringVariable from dify_graph.variables.segments import IntegerSegment, Segment from factories.variable_factory import build_segment -from models.workflow import Workflow, WorkflowDraftVariable, WorkflowNodeExecutionModel, is_system_variable_editable +from models.workflow import ( + Workflow, + WorkflowDraftVariable, + WorkflowNodeExecutionModel, + is_system_variable_editable, +) def test_environment_variables(): @@ -144,6 +150,36 @@ def test_to_dict(): assert workflow_dict["environment_variables"][1]["value"] == "text" +def test_normalize_environment_variable_mappings_converts_full_mask_to_hidden_value(): + normalized = Workflow.normalize_environment_variable_mappings( + [ + { + "id": str(uuid4()), + "name": "secret", + "value": encrypter.full_mask_token(), + "value_type": "secret", + } + ] + ) + + assert normalized[0]["value"] == HIDDEN_VALUE + + +def test_normalize_environment_variable_mappings_keeps_hidden_value(): + normalized = Workflow.normalize_environment_variable_mappings( + [ + { + "id": str(uuid4()), + "name": "secret", + "value": HIDDEN_VALUE, + "value_type": "secret", + } + ] + ) + + assert normalized[0]["value"] == HIDDEN_VALUE + + class TestWorkflowNodeExecution: def test_execution_metadata_dict(self): node_exec = WorkflowNodeExecutionModel() diff --git a/api/tests/unit_tests/services/test_workflow_service.py b/api/tests/unit_tests/services/test_workflow_service.py index 57c0464dc6..753cff8697 100644 --- a/api/tests/unit_tests/services/test_workflow_service.py +++ b/api/tests/unit_tests/services/test_workflow_service.py @@ -544,6 +544,89 @@ class TestWorkflowService: conversation_variables=[], ) + def test_restore_published_workflow_to_draft_keeps_source_features_unmodified( + self, workflow_service, mock_db_session + ): + app = TestWorkflowAssociatedDataFactory.create_app_mock() + account = TestWorkflowAssociatedDataFactory.create_account_mock() + legacy_features = { + "file_upload": { + "image": { + "enabled": True, + "number_limits": 6, + "transfer_methods": ["remote_url", "local_file"], + } + }, + "opening_statement": "", + "retriever_resource": {"enabled": True}, + "sensitive_word_avoidance": {"enabled": False}, + "speech_to_text": {"enabled": False}, + "suggested_questions": [], + "suggested_questions_after_answer": {"enabled": False}, + "text_to_speech": {"enabled": False, "language": "", "voice": ""}, + } + normalized_features = { + "file_upload": { + "enabled": True, + "allowed_file_types": ["image"], + "allowed_file_extensions": [], + "allowed_file_upload_methods": ["remote_url", "local_file"], + "number_limits": 6, + }, + "opening_statement": "", + "retriever_resource": {"enabled": True}, + "sensitive_word_avoidance": {"enabled": False}, + "speech_to_text": {"enabled": False}, + "suggested_questions": [], + "suggested_questions_after_answer": {"enabled": False}, + "text_to_speech": {"enabled": False, "language": "", "voice": ""}, + } + source_workflow = Workflow( + id="published-workflow-id", + tenant_id=app.tenant_id, + app_id=app.id, + type=WorkflowType.WORKFLOW.value, + version="2026-03-19T00:00:00", + graph=json.dumps(TestWorkflowAssociatedDataFactory.create_valid_workflow_graph()), + features=json.dumps(legacy_features), + created_by=account.id, + environment_variables=[], + conversation_variables=[], + rag_pipeline_variables=[], + ) + draft_workflow = Workflow( + id="draft-workflow-id", + tenant_id=app.tenant_id, + app_id=app.id, + type=WorkflowType.WORKFLOW.value, + version=Workflow.VERSION_DRAFT, + graph=json.dumps({"nodes": [], "edges": []}), + features=json.dumps({}), + created_by=account.id, + environment_variables=[], + conversation_variables=[], + rag_pipeline_variables=[], + ) + + with ( + patch.object(workflow_service, "get_published_workflow_by_id", return_value=source_workflow), + patch.object(workflow_service, "get_draft_workflow", return_value=draft_workflow), + patch.object(workflow_service, "validate_graph_structure"), + patch.object(workflow_service, "validate_features_structure") as mock_validate_features, + patch("services.workflow_service.app_draft_workflow_was_synced"), + ): + result = workflow_service.restore_published_workflow_to_draft( + app_model=app, + workflow_id=source_workflow.id, + account=account, + ) + + mock_validate_features.assert_called_once_with(app_model=app, features=normalized_features) + assert result is draft_workflow + assert source_workflow.serialized_features == json.dumps(legacy_features) + assert draft_workflow.serialized_features == json.dumps(legacy_features) + mock_db_session.session.commit.assert_called_once() + # ==================== Workflow Validation Tests ==================== # These tests verify graph structure and feature configuration validation diff --git a/api/tests/unit_tests/services/workflow/test_workflow_restore.py b/api/tests/unit_tests/services/workflow/test_workflow_restore.py new file mode 100644 index 0000000000..179361de45 --- /dev/null +++ b/api/tests/unit_tests/services/workflow/test_workflow_restore.py @@ -0,0 +1,77 @@ +import json +from types import SimpleNamespace + +from models.workflow import Workflow +from services.workflow_restore import apply_published_workflow_snapshot_to_draft + +LEGACY_FEATURES = { + "file_upload": { + "image": { + "enabled": True, + "number_limits": 6, + "transfer_methods": ["remote_url", "local_file"], + } + }, + "opening_statement": "", + "retriever_resource": {"enabled": True}, + "sensitive_word_avoidance": {"enabled": False}, + "speech_to_text": {"enabled": False}, + "suggested_questions": [], + "suggested_questions_after_answer": {"enabled": False}, + "text_to_speech": {"enabled": False, "language": "", "voice": ""}, +} + +NORMALIZED_FEATURES = { + "file_upload": { + "enabled": True, + "allowed_file_types": ["image"], + "allowed_file_extensions": [], + "allowed_file_upload_methods": ["remote_url", "local_file"], + "number_limits": 6, + }, + "opening_statement": "", + "retriever_resource": {"enabled": True}, + "sensitive_word_avoidance": {"enabled": False}, + "speech_to_text": {"enabled": False}, + "suggested_questions": [], + "suggested_questions_after_answer": {"enabled": False}, + "text_to_speech": {"enabled": False, "language": "", "voice": ""}, +} + + +def _create_workflow(*, workflow_id: str, version: str, features: dict[str, object]) -> Workflow: + return Workflow( + id=workflow_id, + tenant_id="tenant-id", + app_id="app-id", + type="workflow", + version=version, + graph=json.dumps({"nodes": [], "edges": []}), + features=json.dumps(features), + created_by="account-id", + environment_variables=[], + conversation_variables=[], + rag_pipeline_variables=[], + ) + + +def test_apply_published_workflow_snapshot_to_draft_copies_serialized_features_without_mutating_source() -> None: + source_workflow = _create_workflow( + workflow_id="published-workflow-id", + version="2026-03-19T00:00:00", + features=LEGACY_FEATURES, + ) + + draft_workflow, is_new_draft = apply_published_workflow_snapshot_to_draft( + tenant_id="tenant-id", + app_id="app-id", + source_workflow=source_workflow, + draft_workflow=None, + account=SimpleNamespace(id="account-id"), + updated_at_factory=lambda: source_workflow.updated_at, + ) + + assert is_new_draft is True + assert source_workflow.serialized_features == json.dumps(LEGACY_FEATURES) + assert source_workflow.normalized_features_dict == NORMALIZED_FEATURES + assert draft_workflow.serialized_features == json.dumps(LEGACY_FEATURES) diff --git a/web/app/components/plugins/plugin-page/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/index.spec.tsx index dafcbe57c2..e02a2bcb57 100644 --- a/web/app/components/plugins/plugin-page/__tests__/index.spec.tsx +++ b/web/app/components/plugins/plugin-page/__tests__/index.spec.tsx @@ -8,6 +8,8 @@ import { usePluginInstallation } from '@/hooks/use-query-params' import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins' import PluginPageWithContext from '../index' +let mockEnableMarketplace = true + // Mock external dependencies vi.mock('@/service/plugins', () => ({ fetchManifestFromMarketPlace: vi.fn(), @@ -31,7 +33,7 @@ vi.mock('@/context/global-public-context', () => ({ useGlobalPublicStore: vi.fn((selector) => { const state = { systemFeatures: { - enable_marketplace: true, + enable_marketplace: mockEnableMarketplace, }, } return selector(state) @@ -138,6 +140,7 @@ const createDefaultProps = (): PluginPageProps => ({ describe('PluginPage Component', () => { beforeEach(() => { vi.clearAllMocks() + mockEnableMarketplace = true // Reset to default mock values vi.mocked(usePluginInstallation).mockReturnValue([ { packageId: null, bundleInfo: null }, @@ -630,18 +633,7 @@ describe('PluginPage Component', () => { }) it('should handle marketplace disabled', () => { - // Mock marketplace disabled - vi.mock('@/context/global-public-context', async () => ({ - useGlobalPublicStore: vi.fn((selector) => { - const state = { - systemFeatures: { - enable_marketplace: false, - }, - } - return selector(state) - }), - })) - + mockEnableMarketplace = false vi.mocked(useQueryState).mockReturnValue(['discover', vi.fn()]) render() diff --git a/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx index 36454d33e4..c3341ecd83 100644 --- a/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx @@ -1,5 +1,6 @@ import type { EnvironmentVariable } from '@/app/components/workflow/types' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { useState } from 'react' import { createMockProviderContextValue } from '@/__mocks__/provider-context' import Conversion from '../conversion' @@ -347,11 +348,67 @@ vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({ ), })) +vi.mock('@/app/components/base/app-icon-picker', () => ({ + default: function MockAppIconPicker({ onSelect, onClose }: { + onSelect?: (payload: + | { type: 'emoji', icon: string, background: string } + | { type: 'image', fileId: string, url: string }, + ) => void + onClose?: () => void + }) { + const [activeTab, setActiveTab] = useState<'emoji' | 'image'>('emoji') + const [selectedEmoji, setSelectedEmoji] = useState({ icon: '😀', background: '#FFFFFF' }) + + return ( +
    + + + {activeTab === 'emoji' && ( + + )} + {activeTab === 'image' &&
    picker-image-panel
    } + + +
    + ) + }, +})) + // Silence expected console.error from Dialog/Modal rendering beforeEach(() => { vi.spyOn(console, 'error').mockImplementation(() => {}) }) +afterEach(() => { + vi.restoreAllMocks() +}) + // Helper to find the name input in PublishAsKnowledgePipelineModal function getNameInput() { return screen.getByPlaceholderText('pipeline.common.publishAsPipeline.namePlaceholder') @@ -708,10 +765,7 @@ describe('PublishAsKnowledgePipelineModal', () => { const appIcon = getAppIcon() fireEvent.click(appIcon) - // Click the first emoji in the grid (search full document since Dialog uses portal) - const gridEmojis = document.querySelectorAll('.grid em-emoji') - expect(gridEmojis.length).toBeGreaterThan(0) - fireEvent.click(gridEmojis[0].parentElement!.parentElement!) + fireEvent.click(screen.getByTestId('picker-emoji-option')) // Click OK to confirm selection fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ })) @@ -1031,11 +1085,8 @@ describe('Integration Tests', () => { // Open picker and select an emoji const appIcon = getAppIcon() fireEvent.click(appIcon) - const gridEmojis = document.querySelectorAll('.grid em-emoji') - if (gridEmojis.length > 0) { - fireEvent.click(gridEmojis[0].parentElement!.parentElement!) - fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ })) - } + fireEvent.click(screen.getByTestId('picker-emoji-option')) + fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ })) fireEvent.click(screen.getByRole('button', { name: /workflow\.common\.publish/i })) diff --git a/web/app/components/rag-pipeline/components/panel/index.tsx b/web/app/components/rag-pipeline/components/panel/index.tsx index 74cdd7034d..8f913956d1 100644 --- a/web/app/components/rag-pipeline/components/panel/index.tsx +++ b/web/app/components/rag-pipeline/components/panel/index.tsx @@ -62,6 +62,7 @@ const RagPipelinePanel = () => { return { getVersionListUrl: `/rag/pipelines/${pipelineId}/workflows`, deleteVersionUrl: (versionId: string) => `/rag/pipelines/${pipelineId}/workflows/${versionId}`, + restoreVersionUrl: (versionId: string) => `/rag/pipelines/${pipelineId}/workflows/${versionId}/restore`, updateVersionUrl: (versionId: string) => `/rag/pipelines/${pipelineId}/workflows/${versionId}`, latestVersionId: '', } diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-nodes-sync-draft.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-nodes-sync-draft.spec.ts index 82635a75b3..6d807565d9 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-nodes-sync-draft.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-nodes-sync-draft.spec.ts @@ -231,6 +231,25 @@ describe('useNodesSyncDraft', () => { expect(mockSyncWorkflowDraft).toHaveBeenCalled() }) + it('should not include source_workflow_id in sync payloads', async () => { + mockGetNodesReadOnly.mockReturnValue(false) + mockGetNodes.mockReturnValue([ + { id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } }, + ]) + + const { result } = renderHook(() => useNodesSyncDraft()) + + await act(async () => { + await result.current.doSyncWorkflowDraft() + }) + + expect(mockSyncWorkflowDraft).toHaveBeenCalledWith(expect.objectContaining({ + params: expect.not.objectContaining({ + source_workflow_id: expect.anything(), + }), + })) + }) + it('should call onSuccess callback when sync succeeds', async () => { mockGetNodesReadOnly.mockReturnValue(false) mockGetNodes.mockReturnValue([ @@ -421,6 +440,21 @@ describe('useNodesSyncDraft', () => { expect(sentParams.rag_pipeline_variables).toEqual([{ variable: 'input', type: 'text-input' }]) }) + it('should not include source_workflow_id when syncing on page close', () => { + mockGetNodes.mockReturnValue([ + { id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } }, + ]) + + const { result } = renderHook(() => useNodesSyncDraft()) + + act(() => { + result.current.syncWorkflowDraftWhenPageClose() + }) + + const sentParams = mockPostWithKeepalive.mock.calls[0][1] + expect(sentParams.source_workflow_id).toBeUndefined() + }) + it('should remove underscore-prefixed keys from edges', () => { mockStoreGetState.mockReturnValue({ getNodes: mockGetNodes, diff --git a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-refresh-draft.spec.ts b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-refresh-draft.spec.ts index 4ad8bc4582..b9cff292e6 100644 --- a/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-refresh-draft.spec.ts +++ b/web/app/components/rag-pipeline/hooks/__tests__/use-pipeline-refresh-draft.spec.ts @@ -35,6 +35,7 @@ describe('usePipelineRefreshDraft', () => { const mockSetIsSyncingWorkflowDraft = vi.fn() const mockSetEnvironmentVariables = vi.fn() const mockSetEnvSecrets = vi.fn() + const mockSetRagPipelineVariables = vi.fn() beforeEach(() => { vi.clearAllMocks() @@ -45,6 +46,7 @@ describe('usePipelineRefreshDraft', () => { setIsSyncingWorkflowDraft: mockSetIsSyncingWorkflowDraft, setEnvironmentVariables: mockSetEnvironmentVariables, setEnvSecrets: mockSetEnvSecrets, + setRagPipelineVariables: mockSetRagPipelineVariables, }) mockFetchWorkflowDraft.mockResolvedValue({ @@ -55,6 +57,7 @@ describe('usePipelineRefreshDraft', () => { }, hash: 'new-hash', environment_variables: [], + rag_pipeline_variables: [], }) }) @@ -116,6 +119,29 @@ describe('usePipelineRefreshDraft', () => { }) }) + it('should update rag pipeline variables after fetch', async () => { + mockFetchWorkflowDraft.mockResolvedValue({ + graph: { + nodes: [], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + hash: 'new-hash', + environment_variables: [], + rag_pipeline_variables: [{ variable: 'query', type: 'text-input' }], + }) + + const { result } = renderHook(() => usePipelineRefreshDraft()) + + act(() => { + result.current.handleRefreshWorkflowDraft() + }) + + await waitFor(() => { + expect(mockSetRagPipelineVariables).toHaveBeenCalledWith([{ variable: 'query', type: 'text-input' }]) + }) + }) + it('should set syncing state to false after completion', async () => { const { result } = renderHook(() => usePipelineRefreshDraft()) diff --git a/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts b/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts index 640da5e8f8..184adb582f 100644 --- a/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts @@ -1,3 +1,4 @@ +import type { SyncDraftCallback } from '@/app/components/workflow/hooks-store' import { produce } from 'immer' import { useCallback } from 'react' import { useStoreApi } from 'reactflow' @@ -83,11 +84,7 @@ export const useNodesSyncDraft = () => { const performSync = useCallback(async ( notRefreshWhenSyncError?: boolean, - callback?: { - onSuccess?: () => void - onError?: () => void - onSettled?: () => void - }, + callback?: SyncDraftCallback, ) => { if (getNodesReadOnly()) return diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline-refresh-draft.ts b/web/app/components/rag-pipeline/hooks/use-pipeline-refresh-draft.ts index 8909af4c4c..c9966a90c5 100644 --- a/web/app/components/rag-pipeline/hooks/use-pipeline-refresh-draft.ts +++ b/web/app/components/rag-pipeline/hooks/use-pipeline-refresh-draft.ts @@ -16,6 +16,7 @@ export const usePipelineRefreshDraft = () => { setIsSyncingWorkflowDraft, setEnvironmentVariables, setEnvSecrets, + setRagPipelineVariables, } = workflowStore.getState() setIsSyncingWorkflowDraft(true) fetchWorkflowDraft(`/rag/pipelines/${pipelineId}/workflows/draft`).then((response) => { @@ -34,6 +35,7 @@ export const usePipelineRefreshDraft = () => { return acc }, {} as Record)) setEnvironmentVariables(response.environment_variables?.map(env => env.value_type === 'secret' ? { ...env, value: '[__HIDDEN__]' } : env) || []) + setRagPipelineVariables?.(response.rag_pipeline_variables || []) }).finally(() => setIsSyncingWorkflowDraft(false)) }, [handleUpdateWorkflowCanvas, workflowStore]) diff --git a/web/app/components/workflow-app/components/workflow-panel.tsx b/web/app/components/workflow-app/components/workflow-panel.tsx index 7f70c53e2e..4b145339d7 100644 --- a/web/app/components/workflow-app/components/workflow-panel.tsx +++ b/web/app/components/workflow-app/components/workflow-panel.tsx @@ -110,6 +110,7 @@ const WorkflowPanel = () => { return { getVersionListUrl: `/apps/${appId}/workflows`, deleteVersionUrl: (versionId: string) => `/apps/${appId}/workflows/${versionId}`, + restoreVersionUrl: (versionId: string) => `/apps/${appId}/workflows/${versionId}/restore`, updateVersionUrl: (versionId: string) => `/apps/${appId}/workflows/${versionId}`, latestVersionId: appDetail?.workflow?.id, } diff --git a/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts index d35e6e3612..fd808affc3 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts @@ -108,4 +108,18 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () => expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled() }) + + it('should not include source_workflow_id in draft sync payloads', async () => { + const { result } = renderHook(() => useNodesSyncDraft()) + + await act(async () => { + await result.current.doSyncWorkflowDraft(false) + }) + + expect(mockSyncWorkflowDraft).toHaveBeenCalledWith(expect.objectContaining({ + params: expect.not.objectContaining({ + source_workflow_id: expect.anything(), + }), + })) + }) }) diff --git a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts index 4f9e529d92..5f61997d9f 100644 --- a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts @@ -1,3 +1,4 @@ +import type { SyncDraftCallback } from '@/app/components/workflow/hooks-store' import { produce } from 'immer' import { useCallback } from 'react' import { useStoreApi } from 'reactflow' @@ -91,11 +92,7 @@ export const useNodesSyncDraft = () => { const performSync = useCallback(async ( notRefreshWhenSyncError?: boolean, - callback?: { - onSuccess?: () => void - onError?: () => void - onSettled?: () => void - }, + callback?: SyncDraftCallback, ) => { if (getNodesReadOnly()) return diff --git a/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx b/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx new file mode 100644 index 0000000000..6fa934b57d --- /dev/null +++ b/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx @@ -0,0 +1,126 @@ +import type { VersionHistory } from '@/types/workflow' +import { screen } from '@testing-library/react' +import { FlowType } from '@/types/common' +import { renderWorkflowComponent } from '../../__tests__/workflow-test-env' +import { WorkflowVersion } from '../../types' +import HeaderInRestoring from '../header-in-restoring' + +const mockRestoreWorkflow = vi.fn() +const mockInvalidAllLastRun = vi.fn() +const mockHandleLoadBackupDraft = vi.fn() +const mockHandleRefreshWorkflowDraft = vi.fn() + +vi.mock('@/hooks/use-theme', () => ({ + default: () => ({ + theme: 'light', + }), +})) + +vi.mock('@/hooks/use-timestamp', () => ({ + default: () => ({ + formatTime: vi.fn(() => '09:30:00'), + }), +})) + +vi.mock('@/hooks/use-format-time-from-now', () => ({ + useFormatTimeFromNow: () => ({ + formatTimeFromNow: vi.fn(() => '3 hours ago'), + }), +})) + +vi.mock('@/service/use-workflow', () => ({ + useInvalidAllLastRun: () => mockInvalidAllLastRun, + useRestoreWorkflow: () => ({ + mutateAsync: mockRestoreWorkflow, + }), +})) + +vi.mock('../../hooks', () => ({ + useWorkflowRun: () => ({ + handleLoadBackupDraft: mockHandleLoadBackupDraft, + }), + useWorkflowRefreshDraft: () => ({ + handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft, + }), +})) + +const createVersion = (overrides: Partial = {}): VersionHistory => ({ + id: 'version-1', + graph: { + nodes: [], + edges: [], + }, + created_at: 1_700_000_000, + created_by: { + id: 'user-1', + name: 'Alice', + email: 'alice@example.com', + }, + hash: 'hash-1', + updated_at: 1_700_000_100, + updated_by: { + id: 'user-2', + name: 'Bob', + email: 'bob@example.com', + }, + tool_published: false, + version: 'v1', + marked_name: 'Release 1', + marked_comment: '', + ...overrides, +}) + +describe('HeaderInRestoring', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should disable restore when the flow id is not ready yet', () => { + renderWorkflowComponent(, { + initialStoreState: { + currentVersion: createVersion(), + }, + hooksStoreProps: { + configsMap: undefined, + }, + }) + + expect(screen.getByRole('button', { name: 'workflow.common.restore' })).toBeDisabled() + }) + + it('should enable restore when version and flow config are both ready', () => { + renderWorkflowComponent(, { + initialStoreState: { + currentVersion: createVersion(), + }, + hooksStoreProps: { + configsMap: { + flowId: 'app-1', + flowType: FlowType.appFlow, + fileSettings: {} as never, + }, + }, + }) + + expect(screen.getByRole('button', { name: 'workflow.common.restore' })).toBeEnabled() + }) + + it('should keep restore disabled for draft versions even when flow config is ready', () => { + renderWorkflowComponent(, { + initialStoreState: { + currentVersion: createVersion({ + version: WorkflowVersion.Draft, + }), + }, + hooksStoreProps: { + configsMap: { + flowId: 'app-1', + flowType: FlowType.appFlow, + fileSettings: {} as never, + }, + }, + }) + + expect(screen.getByRole('button', { name: 'workflow.common.restore' })).toBeDisabled() + }) +}) diff --git a/web/app/components/workflow/header/header-in-restoring.tsx b/web/app/components/workflow/header/header-in-restoring.tsx index e005ce64e8..2c5b4b9f08 100644 --- a/web/app/components/workflow/header/header-in-restoring.tsx +++ b/web/app/components/workflow/header/header-in-restoring.tsx @@ -5,11 +5,12 @@ import { import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import useTheme from '@/hooks/use-theme' -import { useInvalidAllLastRun } from '@/service/use-workflow' +import { useInvalidAllLastRun, useRestoreWorkflow } from '@/service/use-workflow' +import { getFlowPrefix } from '@/service/utils' import { cn } from '@/utils/classnames' import Toast from '../../base/toast' import { - useNodesSyncDraft, + useWorkflowRefreshDraft, useWorkflowRun, } from '../hooks' import { useHooksStore } from '../hooks-store' @@ -42,7 +43,9 @@ const HeaderInRestoring = ({ const { handleLoadBackupDraft, } = useWorkflowRun() - const { handleSyncWorkflowDraft } = useNodesSyncDraft() + const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft() + const { mutateAsync: restoreWorkflow } = useRestoreWorkflow() + const canRestore = !!currentVersion?.id && !!configsMap?.flowId && currentVersion.version !== WorkflowVersion.Draft const handleCancelRestore = useCallback(() => { handleLoadBackupDraft() @@ -50,30 +53,35 @@ const HeaderInRestoring = ({ setShowWorkflowVersionHistoryPanel(false) }, [workflowStore, handleLoadBackupDraft, setShowWorkflowVersionHistoryPanel]) - const handleRestore = useCallback(() => { + const handleRestore = useCallback(async () => { + if (!canRestore) + return + setShowWorkflowVersionHistoryPanel(false) - workflowStore.setState({ isRestoring: false }) - workflowStore.setState({ backupDraft: undefined }) - handleSyncWorkflowDraft(true, false, { - onSuccess: () => { - Toast.notify({ - type: 'success', - message: t('versionHistory.action.restoreSuccess', { ns: 'workflow' }), - }) - }, - onError: () => { - Toast.notify({ - type: 'error', - message: t('versionHistory.action.restoreFailure', { ns: 'workflow' }), - }) - }, - onSettled: () => { - onRestoreSettled?.() - }, - }) - deleteAllInspectVars() - invalidAllLastRun() - }, [setShowWorkflowVersionHistoryPanel, workflowStore, handleSyncWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled]) + const restoreUrl = `/${getFlowPrefix(configsMap.flowType)}/${configsMap.flowId}/workflows/${currentVersion.id}/restore` + + try { + await restoreWorkflow(restoreUrl) + workflowStore.setState({ isRestoring: false }) + workflowStore.setState({ backupDraft: undefined }) + handleRefreshWorkflowDraft() + Toast.notify({ + type: 'success', + message: t('versionHistory.action.restoreSuccess', { ns: 'workflow' }), + }) + deleteAllInspectVars() + invalidAllLastRun() + } + catch { + Toast.notify({ + type: 'error', + message: t('versionHistory.action.restoreFailure', { ns: 'workflow' }), + }) + } + finally { + onRestoreSettled?.() + } + }, [canRestore, currentVersion?.id, configsMap, setShowWorkflowVersionHistoryPanel, workflowStore, restoreWorkflow, handleRefreshWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled]) return ( <> @@ -83,7 +91,7 @@ const HeaderInRestoring = ({
    + } + + return + }, })) vi.mock('@/app/components/app/app-publisher/version-info-modal', () => ({ default: () => null, })) +vi.mock('../version-history-item', () => ({ + default: (props: MockVersionHistoryItemProps) => { + const MockVersionHistoryItem = () => { + const { item, onClick, handleClickMenuItem } = props + + useEffect(() => { + if (item.version === WorkflowVersion.Draft) + onClick(item) + }, [item, onClick]) + + return ( +
    + + {item.version !== WorkflowVersion.Draft && ( + + )} +
    + ) + } + + return + }, +})) + describe('VersionHistoryPanel', () => { beforeEach(() => { vi.clearAllMocks() + mockCurrentVersion = null }) describe('Version Click Behavior', () => { @@ -134,10 +184,10 @@ describe('VersionHistoryPanel', () => { render( `/apps/app-1/workflows/${versionId}/restore`} />, ) - // Draft version auto-clicks on mount via useEffect in VersionHistoryItem expect(mockHandleLoadBackupDraft).toHaveBeenCalled() expect(mockHandleRestoreFromPublishedWorkflow).not.toHaveBeenCalled() }) @@ -148,17 +198,72 @@ describe('VersionHistoryPanel', () => { render( `/apps/app-1/workflows/${versionId}/restore`} />, ) - // Clear mocks after initial render (draft version auto-clicks on mount) vi.clearAllMocks() - const publishedItem = screen.getByText('v1.0') - fireEvent.click(publishedItem) + fireEvent.click(screen.getByText('v1.0')) expect(mockHandleRestoreFromPublishedWorkflow).toHaveBeenCalled() expect(mockHandleLoadBackupDraft).not.toHaveBeenCalled() }) }) + + it('should set current version before confirming restore from context menu', async () => { + const { VersionHistoryPanel } = await import('../index') + + render( + `/apps/app-1/workflows/${versionId}/restore`} + />, + ) + + vi.clearAllMocks() + + fireEvent.click(screen.getByText('restore-published-version-id')) + fireEvent.click(screen.getByText('confirm restore')) + + await waitFor(() => { + expect(mockSetCurrentVersion).toHaveBeenCalledWith(expect.objectContaining({ + id: 'published-version-id', + })) + expect(mockRestoreWorkflow).toHaveBeenCalledWith('/apps/app-1/workflows/published-version-id/restore') + expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ isRestoring: false }) + expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ backupDraft: undefined }) + expect(mockHandleRefreshWorkflowDraft).toHaveBeenCalled() + }) + }) + + it('should keep restore mode backup state when restore request fails', async () => { + const { VersionHistoryPanel } = await import('../index') + mockRestoreWorkflow.mockRejectedValueOnce(new Error('restore failed')) + mockCurrentVersion = createVersionHistory({ + id: 'draft-version-id', + version: WorkflowVersion.Draft, + }) + + render( + `/apps/app-1/workflows/${versionId}/restore`} + />, + ) + + vi.clearAllMocks() + + fireEvent.click(screen.getByText('restore-published-version-id')) + fireEvent.click(screen.getByText('confirm restore')) + + await waitFor(() => { + expect(mockRestoreWorkflow).toHaveBeenCalledWith('/apps/app-1/workflows/published-version-id/restore') + }) + + expect(mockWorkflowStoreSetState).not.toHaveBeenCalledWith({ isRestoring: false }) + expect(mockWorkflowStoreSetState).not.toHaveBeenCalledWith({ backupDraft: undefined }) + expect(mockSetCurrentVersion).not.toHaveBeenCalled() + expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled() + }) }) diff --git a/web/app/components/workflow/panel/version-history-panel/index.tsx b/web/app/components/workflow/panel/version-history-panel/index.tsx index 9439efc918..2815cbf28d 100644 --- a/web/app/components/workflow/panel/version-history-panel/index.tsx +++ b/web/app/components/workflow/panel/version-history-panel/index.tsx @@ -9,8 +9,8 @@ import VersionInfoModal from '@/app/components/app/app-publisher/version-info-mo import Divider from '@/app/components/base/divider' import { toast } from '@/app/components/base/ui/toast' import { useSelector as useAppContextSelector } from '@/context/app-context' -import { useDeleteWorkflow, useInvalidAllLastRun, useResetWorkflowVersionHistory, useUpdateWorkflow, useWorkflowVersionHistory } from '@/service/use-workflow' -import { useDSL, useNodesSyncDraft, useWorkflowRun } from '../../hooks' +import { useDeleteWorkflow, useInvalidAllLastRun, useResetWorkflowVersionHistory, useRestoreWorkflow, useUpdateWorkflow, useWorkflowVersionHistory } from '@/service/use-workflow' +import { useDSL, useWorkflowRefreshDraft, useWorkflowRun } from '../../hooks' import { useHooksStore } from '../../hooks-store' import { useStore, useWorkflowStore } from '../../store' import { VersionHistoryContextMenuOptions, WorkflowVersion, WorkflowVersionFilterOptions } from '../../types' @@ -27,12 +27,14 @@ const INITIAL_PAGE = 1 export type VersionHistoryPanelProps = { getVersionListUrl?: string deleteVersionUrl?: (versionId: string) => string + restoreVersionUrl: (versionId: string) => string updateVersionUrl?: (versionId: string) => string latestVersionId?: string } export const VersionHistoryPanel = ({ getVersionListUrl, deleteVersionUrl, + restoreVersionUrl, updateVersionUrl, latestVersionId, }: VersionHistoryPanelProps) => { @@ -43,8 +45,8 @@ export const VersionHistoryPanel = ({ const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) const [editModalOpen, setEditModalOpen] = useState(false) const workflowStore = useWorkflowStore() - const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { handleRestoreFromPublishedWorkflow, handleLoadBackupDraft } = useWorkflowRun() + const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft() const { handleExportDSL } = useDSL() const setShowWorkflowVersionHistoryPanel = useStore(s => s.setShowWorkflowVersionHistoryPanel) const currentVersion = useStore(s => s.currentVersion) @@ -144,32 +146,33 @@ export const VersionHistoryPanel = ({ }, []) const resetWorkflowVersionHistory = useResetWorkflowVersionHistory() + const { mutateAsync: restoreWorkflow } = useRestoreWorkflow() - const handleRestore = useCallback((item: VersionHistory) => { + const handleRestore = useCallback(async (item: VersionHistory) => { setShowWorkflowVersionHistoryPanel(false) - handleRestoreFromPublishedWorkflow(item) - workflowStore.setState({ isRestoring: false }) - workflowStore.setState({ backupDraft: undefined }) - handleSyncWorkflowDraft(true, false, { - onSuccess: () => { - toast.add({ - type: 'success', - title: t('versionHistory.action.restoreSuccess', { ns: 'workflow' }), - }) - deleteAllInspectVars() - invalidAllLastRun() - }, - onError: () => { - toast.add({ - type: 'error', - title: t('versionHistory.action.restoreFailure', { ns: 'workflow' }), - }) - }, - onSettled: () => { - resetWorkflowVersionHistory() - }, - }) - }, [setShowWorkflowVersionHistoryPanel, handleRestoreFromPublishedWorkflow, workflowStore, handleSyncWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, resetWorkflowVersionHistory]) + try { + await restoreWorkflow(restoreVersionUrl(item.id)) + setCurrentVersion(item) + workflowStore.setState({ isRestoring: false }) + workflowStore.setState({ backupDraft: undefined }) + handleRefreshWorkflowDraft() + toast.add({ + type: 'success', + title: t('versionHistory.action.restoreSuccess', { ns: 'workflow' }), + }) + deleteAllInspectVars() + invalidAllLastRun() + } + catch { + toast.add({ + type: 'error', + title: t('versionHistory.action.restoreFailure', { ns: 'workflow' }), + }) + } + finally { + resetWorkflowVersionHistory() + } + }, [setShowWorkflowVersionHistoryPanel, setCurrentVersion, workflowStore, restoreWorkflow, restoreVersionUrl, handleRefreshWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, resetWorkflowVersionHistory]) const { mutateAsync: deleteWorkflow } = useDeleteWorkflow() diff --git a/web/service/use-workflow.ts b/web/service/use-workflow.ts index fe20b906fc..949658d8ed 100644 --- a/web/service/use-workflow.ts +++ b/web/service/use-workflow.ts @@ -113,6 +113,13 @@ export const useDeleteWorkflow = () => { }) } +export const useRestoreWorkflow = () => { + return useMutation({ + mutationKey: [NAME_SPACE, 'restore'], + mutationFn: (url: string) => post(url, {}, { silent: true }), + }) +} + export const usePublishWorkflow = () => { return useMutation({ mutationKey: [NAME_SPACE, 'publish'],