diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/file-list-item.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/file-list-item.spec.tsx
index df7fe3540b..fcb0878978 100644
--- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/file-list-item.spec.tsx
+++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/file-list-item.spec.tsx
@@ -17,7 +17,7 @@ vi.mock('@/types/app', () => ({
}))
// Mock SimplePieChart with dynamic import handling
-vi.mock('next/dynamic', () => ({
+vi.mock('@/next/dynamic', () => ({
default: () => {
const DynamicComponent = ({ percentage, stroke, fill }: { percentage: number, stroke: string, fill: string }) => (
diff --git a/web/app/components/datasets/documents/create-from-pipeline/left-header.tsx b/web/app/components/datasets/documents/create-from-pipeline/left-header.tsx
index 375886cbc4..d464041d13 100644
--- a/web/app/components/datasets/documents/create-from-pipeline/left-header.tsx
+++ b/web/app/components/datasets/documents/create-from-pipeline/left-header.tsx
@@ -1,10 +1,10 @@
import type { Step } from './step-indicator'
import { RiArrowLeftLine } from '@remixicon/react'
-import { useParams } from 'next/navigation'
import * as React from 'react'
import Button from '@/app/components/base/button'
import Effect from '@/app/components/base/effect'
import Link from '@/next/link'
+import { useParams } from '@/next/navigation'
import StepIndicator from './step-indicator'
type LeftHeaderProps = {
diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx
index 1d0518e11e..f59f5c091b 100644
--- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx
+++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/__tests__/index.spec.tsx
@@ -10,7 +10,7 @@ import { RETRIEVE_METHOD } from '@/types/app'
import EmbeddingProcess from '../index'
const mockPush = vi.fn()
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
diff --git a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx
index 49c85ae433..099c3018cd 100644
--- a/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx
+++ b/web/app/components/datasets/documents/create-from-pipeline/processing/embedding-process/index.tsx
@@ -10,7 +10,6 @@ import {
RiLoader2Fill,
RiTerminalBoxLine,
} from '@remixicon/react'
-import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -26,6 +25,7 @@ import { useProviderContext } from '@/context/provider-context'
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
import { DatasourceType } from '@/models/pipeline'
import Link from '@/next/link'
+import { useRouter } from '@/next/navigation'
import { useIndexingStatusBatch, useProcessRule } from '@/service/knowledge/use-dataset'
import { useInvalidDocumentList } from '@/service/knowledge/use-document'
import { cn } from '@/utils/classnames'
diff --git a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx
index ff0c1b125c..2e121dbbd1 100644
--- a/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx
+++ b/web/app/components/datasets/documents/create-from-pipeline/steps/__tests__/step-one-content.spec.tsx
@@ -143,7 +143,7 @@ vi.mock('@/service/base', () => ({
upload: vi.fn().mockResolvedValue({ id: 'uploaded-file-id' }),
}))
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useParams: () => ({ datasetId: 'mock-dataset-id' }),
useRouter: () => ({ push: vi.fn() }),
usePathname: () => '/datasets/mock-dataset-id',
diff --git a/web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx
index e7945fc409..3eb1017b8d 100644
--- a/web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx
+++ b/web/app/components/datasets/documents/detail/__tests__/document-title.spec.tsx
@@ -5,7 +5,7 @@ import { ChunkingMode } from '@/models/datasets'
import { DocumentTitle } from '../document-title'
const mockPush = vi.fn()
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
diff --git a/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx
index f01a64e34e..be4d2304bd 100644
--- a/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx
+++ b/web/app/components/datasets/documents/detail/__tests__/index.spec.tsx
@@ -25,7 +25,7 @@ const mocks = vi.hoisted(() => {
})
// --- External mocks ---
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: mocks.push }),
useSearchParams: () => new URLSearchParams(mocks.state.searchParams),
}))
diff --git a/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx b/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx
index 73082108a0..dd0cc3cd16 100644
--- a/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx
+++ b/web/app/components/datasets/documents/detail/__tests__/new-segment.spec.tsx
@@ -6,7 +6,7 @@ import { IndexingType } from '../../../create/step-two'
import NewSegmentModal from '../new-segment'
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useParams: () => ({
datasetId: 'test-dataset-id',
documentId: 'test-document-id',
diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx
index 59ecbf5f25..2a68e6f627 100644
--- a/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx
+++ b/web/app/components/datasets/documents/detail/completed/__tests__/index.spec.tsx
@@ -49,7 +49,7 @@ const {
mockOnDelete: vi.fn(),
}))
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
usePathname: () => '/datasets/test-dataset-id/documents/test-document-id',
}))
diff --git a/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx b/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx
index 1b26a15b65..48e8782740 100644
--- a/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx
+++ b/web/app/components/datasets/documents/detail/completed/__tests__/new-child-segment.spec.tsx
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import NewChildSegmentModal from '../new-child-segment'
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useParams: () => ({
datasetId: 'test-dataset-id',
documentId: 'test-document-id',
diff --git a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts
index f54c00e3e7..6e9239c972 100644
--- a/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts
+++ b/web/app/components/datasets/documents/detail/completed/hooks/__tests__/use-segment-list-data.spec.ts
@@ -68,7 +68,7 @@ const {
mockPathname: { current: '/datasets/test/documents/test' },
}))
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
usePathname: () => mockPathname.current,
}))
diff --git a/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.ts b/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.ts
index aa91e9f464..8948f6b547 100644
--- a/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.ts
+++ b/web/app/components/datasets/documents/detail/completed/hooks/use-segment-list-data.ts
@@ -1,12 +1,12 @@
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { SegmentDetailModel, SegmentsResponse, SegmentUpdater } from '@/models/datasets'
import { useQueryClient } from '@tanstack/react-query'
-import { usePathname } from 'next/navigation'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useToastContext } from '@/app/components/base/toast/context'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { ChunkingMode } from '@/models/datasets'
+import { usePathname } from '@/next/navigation'
import {
useChunkListAllKey,
useChunkListDisabledKey,
diff --git a/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx b/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx
index e28fb774fb..edc0fca04c 100644
--- a/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx
+++ b/web/app/components/datasets/documents/detail/completed/new-child-segment.tsx
@@ -1,7 +1,6 @@
import type { FC } from 'react'
import type { ChildChunkDetail, SegmentUpdater } from '@/models/datasets'
import { RiCloseLine, RiExpandDiagonalLine } from '@remixicon/react'
-import { useParams } from 'next/navigation'
import { memo, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
@@ -10,6 +9,7 @@ import { useStore as useAppStore } from '@/app/components/app/store'
import Divider from '@/app/components/base/divider'
import { ToastContext } from '@/app/components/base/toast/context'
import { ChunkingMode } from '@/models/datasets'
+import { useParams } from '@/next/navigation'
import { useAddChildSegment } from '@/service/knowledge/use-segment'
import { cn } from '@/utils/classnames'
import { formatNumber } from '@/utils/format'
diff --git a/web/app/components/datasets/documents/detail/document-title.tsx b/web/app/components/datasets/documents/detail/document-title.tsx
index ec44e3ea97..2190338ab2 100644
--- a/web/app/components/datasets/documents/detail/document-title.tsx
+++ b/web/app/components/datasets/documents/detail/document-title.tsx
@@ -1,6 +1,6 @@
import type { FC } from 'react'
import type { ChunkingMode, ParentMode } from '@/models/datasets'
-import { useRouter } from 'next/navigation'
+import { useRouter } from '@/next/navigation'
import { cn } from '@/utils/classnames'
import DocumentPicker from '../../common/document-picker'
diff --git a/web/app/components/datasets/documents/detail/index.tsx b/web/app/components/datasets/documents/detail/index.tsx
index b6842605c6..891c177169 100644
--- a/web/app/components/datasets/documents/detail/index.tsx
+++ b/web/app/components/datasets/documents/detail/index.tsx
@@ -1,7 +1,6 @@
'use client'
import type { FC } from 'react'
import type { DataSourceInfo, FileItem, FullDocumentDetail, LegacyDataSourceInfo } from '@/models/datasets'
-import { useRouter, useSearchParams } from 'next/navigation'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -13,6 +12,7 @@ import Metadata from '@/app/components/datasets/metadata/metadata-document'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { ChunkingMode } from '@/models/datasets'
+import { useRouter, useSearchParams } from '@/next/navigation'
import { useDocumentDetail, useDocumentMetadata, useInvalidDocumentList } from '@/service/knowledge/use-document'
import { useCheckSegmentBatchImportProgress, useChildSegmentListKey, useSegmentBatchImport, useSegmentListKey } from '@/service/knowledge/use-segment'
import { useInvalid } from '@/service/use-base'
diff --git a/web/app/components/datasets/documents/detail/new-segment.tsx b/web/app/components/datasets/documents/detail/new-segment.tsx
index d2e27e9969..8db909f889 100644
--- a/web/app/components/datasets/documents/detail/new-segment.tsx
+++ b/web/app/components/datasets/documents/detail/new-segment.tsx
@@ -2,7 +2,6 @@ import type { FC } from 'react'
import type { FileEntity } from '@/app/components/datasets/common/image-uploader/types'
import type { SegmentUpdater } from '@/models/datasets'
import { RiCloseLine, RiExpandDiagonalLine } from '@remixicon/react'
-import { useParams } from 'next/navigation'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
@@ -13,6 +12,7 @@ import { ToastContext } from '@/app/components/base/toast/context'
import ImageUploaderInChunk from '@/app/components/datasets/common/image-uploader/image-uploader-in-chunk'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { ChunkingMode } from '@/models/datasets'
+import { useParams } from '@/next/navigation'
import { useAddSegment } from '@/service/knowledge/use-segment'
import { cn } from '@/utils/classnames'
import { formatNumber } from '@/utils/format'
diff --git a/web/app/components/datasets/documents/detail/settings/__tests__/document-settings.spec.tsx b/web/app/components/datasets/documents/detail/settings/__tests__/document-settings.spec.tsx
index 84534298c9..4ac30289e1 100644
--- a/web/app/components/datasets/documents/detail/settings/__tests__/document-settings.spec.tsx
+++ b/web/app/components/datasets/documents/detail/settings/__tests__/document-settings.spec.tsx
@@ -5,7 +5,7 @@ import DocumentSettings from '../document-settings'
const mockPush = vi.fn()
const mockBack = vi.fn()
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,
back: mockBack,
diff --git a/web/app/components/datasets/documents/detail/settings/document-settings.tsx b/web/app/components/datasets/documents/detail/settings/document-settings.tsx
index 67773cb7d6..bcbc149231 100644
--- a/web/app/components/datasets/documents/detail/settings/document-settings.tsx
+++ b/web/app/components/datasets/documents/detail/settings/document-settings.tsx
@@ -11,7 +11,6 @@ import type {
WebsiteCrawlInfo,
} from '@/models/datasets'
import { useBoolean } from 'ahooks'
-import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@@ -24,6 +23,7 @@ import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/con
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import DatasetDetailContext from '@/context/dataset-detail'
+import { useRouter } from '@/next/navigation'
import { useDocumentDetail, useInvalidDocumentDetail, useInvalidDocumentList } from '@/service/knowledge/use-document'
type DocumentSettingsProps = {
diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx
index 9f2ccc0acd..764667c55c 100644
--- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx
+++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/index.spec.tsx
@@ -7,7 +7,7 @@ import PipelineSettings from '../index'
// Mock Next.js router
const mockPush = vi.fn()
const mockBack = vi.fn()
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,
back: mockBack,
diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/left-header.spec.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/left-header.spec.tsx
index 9a1ffab673..30019ca67d 100644
--- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/left-header.spec.tsx
+++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/__tests__/left-header.spec.tsx
@@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import LeftHeader from '../left-header'
const mockBack = vi.fn()
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: () => ({
back: mockBack,
}),
diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx
index 08e13765e5..4c9dd641e3 100644
--- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx
+++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx
@@ -2,13 +2,13 @@ import type { NotionPage } from '@/models/common'
import type { CrawlResultItem, CustomFile, FileIndexingEstimateResponse } from '@/models/datasets'
import type { OnlineDriveFile, PublishedPipelineRunPreviewResponse } from '@/models/pipeline'
import { noop } from 'es-toolkit/function'
-import { useRouter } from 'next/navigation'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppUnavailable from '@/app/components/base/app-unavailable'
import Loading from '@/app/components/base/loading'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { DatasourceType } from '@/models/pipeline'
+import { useRouter } from '@/next/navigation'
import { useInvalidDocumentDetail, useInvalidDocumentList } from '@/service/knowledge/use-document'
import { usePipelineExecutionLog, useRunPublishedPipeline } from '@/service/use-pipeline'
import ChunkPreview from '../../../create-from-pipeline/preview/chunk-preview'
diff --git a/web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.tsx b/web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.tsx
index 280d835586..15b06a5f10 100644
--- a/web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.tsx
+++ b/web/app/components/datasets/documents/detail/settings/pipeline-settings/left-header.tsx
@@ -1,10 +1,10 @@
import { RiArrowLeftLine } from '@remixicon/react'
-import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Effect from '@/app/components/base/effect'
+import { useRouter } from '@/next/navigation'
type LeftHeaderProps = {
title: string
diff --git a/web/app/components/datasets/documents/index.tsx b/web/app/components/datasets/documents/index.tsx
index 764b04227c..29d9c01f71 100644
--- a/web/app/components/datasets/documents/index.tsx
+++ b/web/app/components/datasets/documents/index.tsx
@@ -1,11 +1,11 @@
'use client'
import type { FC } from 'react'
-import { useRouter } from 'next/navigation'
import { useCallback } from 'react'
import Loading from '@/app/components/base/loading'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useProviderContext } from '@/context/provider-context'
import { DataSourceType } from '@/models/datasets'
+import { useRouter } from '@/next/navigation'
import { useDocumentList, useInvalidDocumentDetail, useInvalidDocumentList } from '@/service/knowledge/use-document'
import { useChildSegmentListKey, useSegmentListKey } from '@/service/knowledge/use-segment'
import { useInvalid } from '@/service/use-base'
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 a6a60aa856..64b24fb08f 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
@@ -7,7 +7,7 @@ import ExternalKnowledgeBaseConnector from '../index'
const mockRouterBack = vi.fn()
const mockReplace = vi.fn()
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: () => ({
back: mockRouterBack,
replace: mockReplace,
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 cf36eed382..789e92c668 100644
--- a/web/app/components/datasets/external-knowledge-base/connector/index.tsx
+++ b/web/app/components/datasets/external-knowledge-base/connector/index.tsx
@@ -1,12 +1,12 @@
'use client'
import type { CreateKnowledgeBaseReq } from '@/app/components/datasets/external-knowledge-base/create/declarations'
-import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useState } from 'react'
import { trackEvent } from '@/app/components/base/amplitude'
import { useToastContext } from '@/app/components/base/toast/context'
import ExternalKnowledgeBaseCreate from '@/app/components/datasets/external-knowledge-base/create'
+import { useRouter } from '@/next/navigation'
import { createExternalKnowledgeBase } from '@/service/datasets'
const ExternalKnowledgeBaseConnector = () => {
diff --git a/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelect.tsx b/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelect.tsx
index f84e6c57c1..a527da982a 100644
--- a/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelect.tsx
+++ b/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelect.tsx
@@ -2,13 +2,13 @@ import {
RiAddLine,
RiArrowDownSLine,
} from '@remixicon/react'
-import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development'
import { useExternalKnowledgeApi } from '@/context/external-knowledge-api-context'
import { useModalContext } from '@/context/modal-context'
+import { useRouter } from '@/next/navigation'
type ApiItem = {
value: string
diff --git a/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelection.tsx b/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelection.tsx
index 75b9e8de9c..4652a8a5f1 100644
--- a/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelection.tsx
+++ b/web/app/components/datasets/external-knowledge-base/create/ExternalApiSelection.tsx
@@ -1,7 +1,6 @@
'use client'
import { RiAddLine } from '@remixicon/react'
-import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -9,6 +8,7 @@ import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import { useExternalKnowledgeApi } from '@/context/external-knowledge-api-context'
import { useModalContext } from '@/context/modal-context'
+import { useRouter } from '@/next/navigation'
import ExternalApiSelect from './ExternalApiSelect'
type ExternalApiSelectionProps = {
diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelect.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelect.spec.tsx
index 3b8b35a5b7..7af75fbcdd 100644
--- a/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelect.spec.tsx
+++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelect.spec.tsx
@@ -12,7 +12,7 @@ const mocks = vi.hoisted(() => ({
mutateExternalKnowledgeApis: vi.fn(),
}))
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: mocks.push, refresh: mocks.refresh }),
}))
diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx
index 702890bee9..97934f36e1 100644
--- a/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx
+++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/ExternalApiSelection.spec.tsx
@@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => ({
externalKnowledgeApiList: [] as Array<{ id: string, name: string, settings: { endpoint: string } }>,
}))
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: mocks.push, refresh: mocks.refresh }),
}))
diff --git a/web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx
index 213fe30ee3..a3282e441c 100644
--- a/web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx
+++ b/web/app/components/datasets/external-knowledge-base/create/__tests__/index.spec.tsx
@@ -7,7 +7,7 @@ import RetrievalSettings from '../RetrievalSettings'
const mockReplace = vi.fn()
const mockRefresh = vi.fn()
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: () => ({
replace: mockReplace,
push: vi.fn(),
diff --git a/web/app/components/datasets/external-knowledge-base/create/index.tsx b/web/app/components/datasets/external-knowledge-base/create/index.tsx
index 07b6e71fa6..0e855259ba 100644
--- a/web/app/components/datasets/external-knowledge-base/create/index.tsx
+++ b/web/app/components/datasets/external-knowledge-base/create/index.tsx
@@ -2,12 +2,12 @@
import type { CreateKnowledgeBaseReq } from './declarations'
import { RiArrowLeftLine, RiArrowRightLine } from '@remixicon/react'
-import { useRouter } from 'next/navigation'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import { useDocLink } from '@/context/i18n'
+import { useRouter } from '@/next/navigation'
import ExternalApiSelection from './ExternalApiSelection'
import InfoPanel from './InfoPanel'
import KnowledgeBaseInfo from './KnowledgeBaseInfo'
diff --git a/web/app/components/datasets/extra-info/__tests__/index.spec.tsx b/web/app/components/datasets/extra-info/__tests__/index.spec.tsx
index c6f9066e6b..de61894a11 100644
--- a/web/app/components/datasets/extra-info/__tests__/index.spec.tsx
+++ b/web/app/components/datasets/extra-info/__tests__/index.spec.tsx
@@ -13,7 +13,7 @@ import Statistics from '../statistics'
// Mock Setup
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
diff --git a/web/app/components/datasets/extra-info/service-api/__tests__/index.spec.tsx b/web/app/components/datasets/extra-info/service-api/__tests__/index.spec.tsx
index 201649556f..8137052383 100644
--- a/web/app/components/datasets/extra-info/service-api/__tests__/index.spec.tsx
+++ b/web/app/components/datasets/extra-info/service-api/__tests__/index.spec.tsx
@@ -9,7 +9,7 @@ import ServiceApi from '../index'
// Mock Setup
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
diff --git a/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx b/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx
index fe7510b498..2dda6ecaae 100644
--- a/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx
+++ b/web/app/components/datasets/hit-testing/__tests__/index.spec.tsx
@@ -27,7 +27,7 @@ vi.mock('@/app/components/datasets/external-knowledge-base/create/RetrievalSetti
// Mock Setup
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
diff --git a/web/app/components/datasets/list/__tests__/datasets.spec.tsx b/web/app/components/datasets/list/__tests__/datasets.spec.tsx
index 49bda88c8b..5b777e0b2e 100644
--- a/web/app/components/datasets/list/__tests__/datasets.spec.tsx
+++ b/web/app/components/datasets/list/__tests__/datasets.spec.tsx
@@ -6,7 +6,7 @@ import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datase
import { RETRIEVE_METHOD } from '@/types/app'
import Datasets from '../datasets'
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
}))
diff --git a/web/app/components/datasets/list/__tests__/index.spec.tsx b/web/app/components/datasets/list/__tests__/index.spec.tsx
index 73e0ba0960..37a787ff51 100644
--- a/web/app/components/datasets/list/__tests__/index.spec.tsx
+++ b/web/app/components/datasets/list/__tests__/index.spec.tsx
@@ -4,7 +4,7 @@ import List from '../index'
const mockPush = vi.fn()
const mockReplace = vi.fn()
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,
replace: mockReplace,
diff --git a/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx b/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx
index ebe80e4686..21ddda5ce6 100644
--- a/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx
+++ b/web/app/components/datasets/list/dataset-card/__tests__/index.spec.tsx
@@ -22,7 +22,7 @@ vi.mock('@/hooks/use-format-time-from-now', () => ({
const mockPush = vi.fn()
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}))
diff --git a/web/app/components/datasets/list/dataset-card/index.tsx b/web/app/components/datasets/list/dataset-card/index.tsx
index 85dba7e8ff..2a22255eda 100644
--- a/web/app/components/datasets/list/dataset-card/index.tsx
+++ b/web/app/components/datasets/list/dataset-card/index.tsx
@@ -1,9 +1,9 @@
'use client'
import type { DataSet } from '@/models/datasets'
import { useHover } from 'ahooks'
-import { useRouter } from 'next/navigation'
import { useMemo, useRef } from 'react'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
+import { useRouter } from '@/next/navigation'
import CornerLabels from './components/corner-labels'
import DatasetCardFooter from './components/dataset-card-footer'
import DatasetCardHeader from './components/dataset-card-header'
diff --git a/web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx b/web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx
index e56fe46422..9cc4f89bd8 100644
--- a/web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx
+++ b/web/app/components/datasets/metadata/metadata-document/__tests__/index.spec.tsx
@@ -45,7 +45,7 @@ vi.mock('../../hooks/use-check-metadata-name', () => ({
}),
}))
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
}),
diff --git a/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx b/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx
index f30e188cd7..d783b882a8 100644
--- a/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx
+++ b/web/app/components/datasets/metadata/metadata-document/__tests__/info-group.spec.tsx
@@ -22,7 +22,7 @@ type InputCombinedProps = {
type: DataType
}
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
}),
diff --git a/web/app/components/datasets/metadata/metadata-document/info-group.tsx b/web/app/components/datasets/metadata/metadata-document/info-group.tsx
index 6d172c92f4..0b21d607bd 100644
--- a/web/app/components/datasets/metadata/metadata-document/info-group.tsx
+++ b/web/app/components/datasets/metadata/metadata-document/info-group.tsx
@@ -2,12 +2,12 @@
import type { FC } from 'react'
import type { MetadataItemWithValue } from '../types'
import { RiDeleteBinLine, RiQuestionLine } from '@remixicon/react'
-import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import Tooltip from '@/app/components/base/tooltip'
import useTimestamp from '@/hooks/use-timestamp'
+import { useRouter } from '@/next/navigation'
import { cn } from '@/utils/classnames'
import AddMetadataButton from '../add-metadata-button'
import InputCombined from '../edit-metadata-batch/input-combined'
diff --git a/web/app/components/explore/__tests__/index.spec.tsx b/web/app/components/explore/__tests__/index.spec.tsx
index cf76593613..5c743928e8 100644
--- a/web/app/components/explore/__tests__/index.spec.tsx
+++ b/web/app/components/explore/__tests__/index.spec.tsx
@@ -8,7 +8,7 @@ const mockReplace = vi.fn()
const mockPush = vi.fn()
const mockInstalledAppsData = { installed_apps: [] as const }
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: () => ({
replace: mockReplace,
push: mockPush,
diff --git a/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx b/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx
index 62353fb3c1..f389eeab29 100644
--- a/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx
+++ b/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx
@@ -19,7 +19,7 @@ vi.mock('@emoji-mart/data', () => ({
},
}))
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useParams: () => ({}),
}))
diff --git a/web/app/components/explore/sidebar/__tests__/index.spec.tsx b/web/app/components/explore/sidebar/__tests__/index.spec.tsx
index 36e6ab217c..26c065a10c 100644
--- a/web/app/components/explore/sidebar/__tests__/index.spec.tsx
+++ b/web/app/components/explore/sidebar/__tests__/index.spec.tsx
@@ -13,7 +13,7 @@ let mockIsPending = false
let mockInstalledApps: InstalledApp[] = []
let mockMediaType: string = MediaType.pc
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useSelectedLayoutSegments: () => mockSegments,
useRouter: () => ({
push: mockPush,
diff --git a/web/app/components/explore/sidebar/app-nav-item/__tests__/index.spec.tsx b/web/app/components/explore/sidebar/app-nav-item/__tests__/index.spec.tsx
index 299c181c98..26af458c55 100644
--- a/web/app/components/explore/sidebar/app-nav-item/__tests__/index.spec.tsx
+++ b/web/app/components/explore/sidebar/app-nav-item/__tests__/index.spec.tsx
@@ -3,7 +3,7 @@ import AppNavItem from '../index'
const mockPush = vi.fn()
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
diff --git a/web/app/components/explore/sidebar/app-nav-item/index.tsx b/web/app/components/explore/sidebar/app-nav-item/index.tsx
index 08558578f6..3f3d7a727e 100644
--- a/web/app/components/explore/sidebar/app-nav-item/index.tsx
+++ b/web/app/components/explore/sidebar/app-nav-item/index.tsx
@@ -2,11 +2,11 @@
import type { AppIconType } from '@/types/app'
import { useHover } from 'ahooks'
-import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useRef } from 'react'
import AppIcon from '@/app/components/base/app-icon'
import ItemOperation from '@/app/components/explore/item-operation'
+import { useRouter } from '@/next/navigation'
import { cn } from '@/utils/classnames'
export type IAppNavItemProps = {
diff --git a/web/app/components/explore/sidebar/index.tsx b/web/app/components/explore/sidebar/index.tsx
index d8d636285b..4b328bb46d 100644
--- a/web/app/components/explore/sidebar/index.tsx
+++ b/web/app/components/explore/sidebar/index.tsx
@@ -1,6 +1,5 @@
'use client'
import { useBoolean } from 'ahooks'
-import { useSelectedLayoutSegments } from 'next/navigation'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -8,6 +7,7 @@ import Confirm from '@/app/components/base/confirm'
import Divider from '@/app/components/base/divider'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import Link from '@/next/link'
+import { useSelectedLayoutSegments } from '@/next/navigation'
import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/service/use-explore'
import { cn } from '@/utils/classnames'
import Toast from '../../base/toast'
diff --git a/web/app/components/goto-anything/__tests__/command-selector.spec.tsx b/web/app/components/goto-anything/__tests__/command-selector.spec.tsx
index 56e40a71f0..98c6ac784f 100644
--- a/web/app/components/goto-anything/__tests__/command-selector.spec.tsx
+++ b/web/app/components/goto-anything/__tests__/command-selector.spec.tsx
@@ -5,7 +5,7 @@ import { Command } from 'cmdk'
import * as React from 'react'
import CommandSelector from '../command-selector'
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
usePathname: () => '/app',
}))
diff --git a/web/app/components/goto-anything/__tests__/context.spec.tsx b/web/app/components/goto-anything/__tests__/context.spec.tsx
index c427f76c61..70a30786df 100644
--- a/web/app/components/goto-anything/__tests__/context.spec.tsx
+++ b/web/app/components/goto-anything/__tests__/context.spec.tsx
@@ -3,7 +3,7 @@ import * as React from 'react'
import { GotoAnythingProvider, useGotoAnythingContext } from '../context'
let pathnameMock: string | null | undefined = '/'
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
usePathname: () => pathnameMock,
}))
diff --git a/web/app/components/goto-anything/__tests__/index.spec.tsx b/web/app/components/goto-anything/__tests__/index.spec.tsx
index eb5fa8ccdd..b2050ef9fb 100644
--- a/web/app/components/goto-anything/__tests__/index.spec.tsx
+++ b/web/app/components/goto-anything/__tests__/index.spec.tsx
@@ -11,7 +11,7 @@ type TestSearchResult = Omit
& {
}
const routerPush = vi.fn()
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: routerPush,
}),
diff --git a/web/app/components/goto-anything/command-selector.tsx b/web/app/components/goto-anything/command-selector.tsx
index bdb641cae6..59373c9e3a 100644
--- a/web/app/components/goto-anything/command-selector.tsx
+++ b/web/app/components/goto-anything/command-selector.tsx
@@ -1,9 +1,9 @@
import type { FC } from 'react'
import type { ActionItem } from './actions/types'
import { Command } from 'cmdk'
-import { usePathname } from 'next/navigation'
import { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
+import { usePathname } from '@/next/navigation'
import { slashCommandRegistry } from './actions/commands/registry'
type Props = {
diff --git a/web/app/components/goto-anything/context.tsx b/web/app/components/goto-anything/context.tsx
index 5c2bf3cb6b..28fb08ac17 100644
--- a/web/app/components/goto-anything/context.tsx
+++ b/web/app/components/goto-anything/context.tsx
@@ -1,9 +1,9 @@
'use client'
import type { ReactNode } from 'react'
-import { usePathname } from 'next/navigation'
import * as React from 'react'
import { createContext, useContext, useEffect, useState } from 'react'
+import { usePathname } from '@/next/navigation'
import { isInWorkflowPage } from '../workflow/constants'
/**
diff --git a/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-navigation.spec.ts b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-navigation.spec.ts
index 1ac3bbc17c..c8a6a4a13c 100644
--- a/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-navigation.spec.ts
+++ b/web/app/components/goto-anything/hooks/__tests__/use-goto-anything-navigation.spec.ts
@@ -16,7 +16,7 @@ type MockCommandResult = {
let mockFindCommandResult: MockCommandResult = null
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockRouterPush,
}),
diff --git a/web/app/components/goto-anything/hooks/use-goto-anything-navigation.ts b/web/app/components/goto-anything/hooks/use-goto-anything-navigation.ts
index 73be6cd3ee..9c9871fa1d 100644
--- a/web/app/components/goto-anything/hooks/use-goto-anything-navigation.ts
+++ b/web/app/components/goto-anything/hooks/use-goto-anything-navigation.ts
@@ -3,9 +3,9 @@
import type { RefObject } from 'react'
import type { Plugin } from '../../plugins/types'
import type { ActionItem, SearchResult } from '../actions/types'
-import { useRouter } from 'next/navigation'
import { useCallback, useState } from 'react'
import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigation'
+import { useRouter } from '@/next/navigation'
import { slashCommandRegistry } from '../actions/commands/registry'
export type UseGotoAnythingNavigationReturn = {
diff --git a/web/app/components/header/__tests__/header-wrapper.spec.tsx b/web/app/components/header/__tests__/header-wrapper.spec.tsx
index b1948e0992..cdb6a7a849 100644
--- a/web/app/components/header/__tests__/header-wrapper.spec.tsx
+++ b/web/app/components/header/__tests__/header-wrapper.spec.tsx
@@ -1,10 +1,10 @@
import { act, render, screen } from '@testing-library/react'
-import { usePathname } from 'next/navigation'
import { vi } from 'vitest'
import { useEventEmitterContextContext } from '@/context/event-emitter'
+import { usePathname } from '@/next/navigation'
import HeaderWrapper from '../header-wrapper'
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
usePathname: vi.fn(),
}))
diff --git a/web/app/components/header/account-dropdown/__tests__/index.spec.tsx b/web/app/components/header/account-dropdown/__tests__/index.spec.tsx
index e1d4c45810..eb4d543e66 100644
--- a/web/app/components/header/account-dropdown/__tests__/index.spec.tsx
+++ b/web/app/components/header/account-dropdown/__tests__/index.spec.tsx
@@ -3,12 +3,12 @@ import type { ModalContextState } from '@/context/modal-context'
import type { ProviderContextState } from '@/context/provider-context'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
-import { useRouter } from 'next/navigation'
import { Plan } from '@/app/components/billing/type'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
+import { useRouter } from '@/next/navigation'
import { useLogout } from '@/service/use-common'
import AppSelector from '../index'
@@ -53,8 +53,8 @@ vi.mock('@/service/use-common', () => ({
useLogout: vi.fn(),
}))
-vi.mock('next/navigation', async (importOriginal) => {
- const actual = await importOriginal()
+vi.mock('@/next/navigation', async (importOriginal) => {
+ const actual = await importOriginal()
return {
...actual,
useRouter: vi.fn(),
diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx
index 7048ccbde0..1697433ac4 100644
--- a/web/app/components/header/account-dropdown/index.tsx
+++ b/web/app/components/header/account-dropdown/index.tsx
@@ -1,7 +1,6 @@
'use client'
import type { MouseEventHandler, ReactNode } from 'react'
-import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { resetUser } from '@/app/components/base/amplitude/utils'
@@ -18,6 +17,7 @@ import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { env } from '@/env'
import Link from '@/next/link'
+import { useRouter } from '@/next/navigation'
import { useLogout } from '@/service/use-common'
import { cn } from '@/utils/classnames'
import AccountAbout from '../account-about'
diff --git a/web/app/components/header/account-setting/__tests__/index.spec.tsx b/web/app/components/header/account-setting/__tests__/index.spec.tsx
index 38cbb58a1b..2aa9db4771 100644
--- a/web/app/components/header/account-setting/__tests__/index.spec.tsx
+++ b/web/app/components/header/account-setting/__tests__/index.spec.tsx
@@ -27,7 +27,7 @@ vi.mock('@/context/app-context', async (importOriginal) => {
}
})
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: vi.fn(() => ({
push: vi.fn(),
replace: vi.fn(),
diff --git a/web/app/components/header/account-setting/language-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/language-page/__tests__/index.spec.tsx
index fb032ebd62..eafd57ed66 100644
--- a/web/app/components/header/account-setting/language-page/__tests__/index.spec.tsx
+++ b/web/app/components/header/account-setting/language-page/__tests__/index.spec.tsx
@@ -61,7 +61,7 @@ vi.mock('@/app/components/base/select', async () => {
}
})
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: () => ({ refresh: mockRefresh }),
}))
diff --git a/web/app/components/header/account-setting/language-page/index.tsx b/web/app/components/header/account-setting/language-page/index.tsx
index 5751e88285..6c84a25428 100644
--- a/web/app/components/header/account-setting/language-page/index.tsx
+++ b/web/app/components/header/account-setting/language-page/index.tsx
@@ -2,7 +2,6 @@
import type { Item } from '@/app/components/base/select'
import type { Locale } from '@/i18n-config'
-import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
@@ -12,6 +11,7 @@ import { useAppContext } from '@/context/app-context'
import { useLocale } from '@/context/i18n'
import { setLocaleOnClient } from '@/i18n-config'
import { languages } from '@/i18n-config/language'
+import { useRouter } from '@/next/navigation'
import { updateUserProfile } from '@/service/common'
import { timezones } from '@/utils/timezone'
diff --git a/web/app/components/header/app-nav/__tests__/index.spec.tsx b/web/app/components/header/app-nav/__tests__/index.spec.tsx
index 0ccb468670..03f8edfacf 100644
--- a/web/app/components/header/app-nav/__tests__/index.spec.tsx
+++ b/web/app/components/header/app-nav/__tests__/index.spec.tsx
@@ -1,13 +1,13 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
-import { useParams } from 'next/navigation'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useAppContext } from '@/context/app-context'
+import { useParams } from '@/next/navigation'
import { useInfiniteAppList } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import AppNav from '../index'
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useParams: vi.fn(),
}))
diff --git a/web/app/components/header/app-nav/index.tsx b/web/app/components/header/app-nav/index.tsx
index 737dd96bab..214b7612bb 100644
--- a/web/app/components/header/app-nav/index.tsx
+++ b/web/app/components/header/app-nav/index.tsx
@@ -7,7 +7,6 @@ import {
} from '@remixicon/react'
import { flatten } from 'es-toolkit/compat'
import { produce } from 'immer'
-import { useParams } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import CreateAppTemplateDialog from '@/app/components/app/create-app-dialog'
@@ -15,6 +14,7 @@ import CreateAppModal from '@/app/components/app/create-app-modal'
import CreateFromDSLModal from '@/app/components/app/create-from-dsl-modal'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useAppContext } from '@/context/app-context'
+import { useParams } from '@/next/navigation'
import { useInfiniteAppList } from '@/service/use-apps'
import { AppModeEnum } from '@/types/app'
import Nav from '../nav'
diff --git a/web/app/components/header/app-selector/__tests__/index.spec.tsx b/web/app/components/header/app-selector/__tests__/index.spec.tsx
index 676aba7023..eddb7e52aa 100644
--- a/web/app/components/header/app-selector/__tests__/index.spec.tsx
+++ b/web/app/components/header/app-selector/__tests__/index.spec.tsx
@@ -1,12 +1,12 @@
import type { AppDetailResponse } from '@/models/app'
import { act, fireEvent, render, screen } from '@testing-library/react'
-import { useRouter } from 'next/navigation'
import { vi } from 'vitest'
import { useAppContext } from '@/context/app-context'
+import { useRouter } from '@/next/navigation'
import AppSelector from '../index'
// Mock next/navigation
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: vi.fn(),
}))
diff --git a/web/app/components/header/app-selector/index.tsx b/web/app/components/header/app-selector/index.tsx
index 13677ef7ab..89f87c2687 100644
--- a/web/app/components/header/app-selector/index.tsx
+++ b/web/app/components/header/app-selector/index.tsx
@@ -3,12 +3,12 @@ import type { AppDetailResponse } from '@/models/app'
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import { ChevronDownIcon, PlusIcon } from '@heroicons/react/24/solid'
import { noop } from 'es-toolkit/function'
-import { useRouter } from 'next/navigation'
import { Fragment, useState } from 'react'
import { useTranslation } from 'react-i18next'
import CreateAppDialog from '@/app/components/app/create-app-dialog'
import AppIcon from '@/app/components/base/app-icon'
import { useAppContext } from '@/context/app-context'
+import { useRouter } from '@/next/navigation'
import Indicator from '../indicator'
type IAppSelectorProps = {
diff --git a/web/app/components/header/dataset-nav/__tests__/index.spec.tsx b/web/app/components/header/dataset-nav/__tests__/index.spec.tsx
index a551538e98..a81fa0ca3f 100644
--- a/web/app/components/header/dataset-nav/__tests__/index.spec.tsx
+++ b/web/app/components/header/dataset-nav/__tests__/index.spec.tsx
@@ -1,18 +1,18 @@
import { act, fireEvent, render, screen, within } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { useAppContext } from '@/context/app-context'
import {
useParams,
useRouter,
useSelectedLayoutSegment,
-} from 'next/navigation'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { useAppContext } from '@/context/app-context'
+} from '@/next/navigation'
import {
useDatasetDetail,
useDatasetList,
} from '@/service/knowledge/use-dataset'
import DatasetNav from '../index'
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useParams: vi.fn(),
useRouter: vi.fn(),
useSelectedLayoutSegment: vi.fn(),
diff --git a/web/app/components/header/dataset-nav/index.tsx b/web/app/components/header/dataset-nav/index.tsx
index c0e79128e9..0b39560884 100644
--- a/web/app/components/header/dataset-nav/index.tsx
+++ b/web/app/components/header/dataset-nav/index.tsx
@@ -7,9 +7,9 @@ import {
RiBook2Line,
} from '@remixicon/react'
import { flatten } from 'es-toolkit/compat'
-import { useParams, useRouter } from 'next/navigation'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
+import { useParams, useRouter } from '@/next/navigation'
import { useDatasetDetail, useDatasetList } from '@/service/knowledge/use-dataset'
import { basePath } from '@/utils/var'
import Nav from '../nav'
diff --git a/web/app/components/header/explore-nav/__tests__/index.spec.tsx b/web/app/components/header/explore-nav/__tests__/index.spec.tsx
index 79285cf53e..0ef271b034 100644
--- a/web/app/components/header/explore-nav/__tests__/index.spec.tsx
+++ b/web/app/components/header/explore-nav/__tests__/index.spec.tsx
@@ -1,9 +1,9 @@
import type { Mock } from 'vitest'
import { render, screen } from '@testing-library/react'
-import { useSelectedLayoutSegment } from 'next/navigation'
+import { useSelectedLayoutSegment } from '@/next/navigation'
import ExploreNav from '../index'
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useSelectedLayoutSegment: vi.fn(),
}))
diff --git a/web/app/components/header/explore-nav/index.tsx b/web/app/components/header/explore-nav/index.tsx
index a6f9faf24e..9931690e83 100644
--- a/web/app/components/header/explore-nav/index.tsx
+++ b/web/app/components/header/explore-nav/index.tsx
@@ -4,9 +4,9 @@ import {
RiPlanetFill,
RiPlanetLine,
} from '@remixicon/react'
-import { useSelectedLayoutSegment } from 'next/navigation'
import { useTranslation } from 'react-i18next'
import Link from '@/next/link'
+import { useSelectedLayoutSegment } from '@/next/navigation'
import { cn } from '@/utils/classnames'
type ExploreNavProps = {
diff --git a/web/app/components/header/header-wrapper.tsx b/web/app/components/header/header-wrapper.tsx
index 1b81c1152c..e140939976 100644
--- a/web/app/components/header/header-wrapper.tsx
+++ b/web/app/components/header/header-wrapper.tsx
@@ -1,8 +1,8 @@
'use client'
-import { usePathname } from 'next/navigation'
import * as React from 'react'
import { useState } from 'react'
import { useEventEmitterContextContext } from '@/context/event-emitter'
+import { usePathname } from '@/next/navigation'
import { cn } from '@/utils/classnames'
import s from './index.module.css'
diff --git a/web/app/components/header/nav/__tests__/index.spec.tsx b/web/app/components/header/nav/__tests__/index.spec.tsx
index a47dc711c8..6ee8a7a924 100644
--- a/web/app/components/header/nav/__tests__/index.spec.tsx
+++ b/web/app/components/header/nav/__tests__/index.spec.tsx
@@ -7,11 +7,11 @@ import {
screen,
waitFor,
} from '@testing-library/react'
-import { useRouter, useSelectedLayoutSegment } from 'next/navigation'
import * as React from 'react'
import { vi } from 'vitest'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useAppContext } from '@/context/app-context'
+import { useRouter, useSelectedLayoutSegment } from '@/next/navigation'
import { AppModeEnum } from '@/types/app'
import Nav from '../index'
@@ -69,7 +69,7 @@ vi.mock('@headlessui/react', () => {
})
// Mock next/navigation
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useSelectedLayoutSegment: vi.fn(),
useRouter: vi.fn(),
}))
diff --git a/web/app/components/header/nav/index.tsx b/web/app/components/header/nav/index.tsx
index 5d86a77d04..1342bb1fa3 100644
--- a/web/app/components/header/nav/index.tsx
+++ b/web/app/components/header/nav/index.tsx
@@ -1,12 +1,12 @@
'use client'
import type { INavSelectorProps } from './nav-selector'
-import { useSelectedLayoutSegment } from 'next/navigation'
import * as React from 'react'
import { useState } from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
import Link from '@/next/link'
+import { useSelectedLayoutSegment } from '@/next/navigation'
import { cn } from '@/utils/classnames'
import NavSelector from './nav-selector'
diff --git a/web/app/components/header/nav/nav-selector/__tests__/index.spec.tsx b/web/app/components/header/nav/nav-selector/__tests__/index.spec.tsx
index 55d77389c6..152901b79c 100644
--- a/web/app/components/header/nav/nav-selector/__tests__/index.spec.tsx
+++ b/web/app/components/header/nav/nav-selector/__tests__/index.spec.tsx
@@ -1,11 +1,11 @@
import type { INavSelectorProps, NavItem } from '../index'
import type { AppContextValue } from '@/context/app-context'
import { act, fireEvent, render, screen } from '@testing-library/react'
-import { useRouter } from 'next/navigation'
import * as React from 'react'
import { vi } from 'vitest'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useAppContext } from '@/context/app-context'
+import { useRouter } from '@/next/navigation'
import { AppModeEnum } from '@/types/app'
import NavSelector from '../index'
@@ -63,7 +63,7 @@ vi.mock('@headlessui/react', () => {
})
// Mock next/navigation
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: vi.fn(),
}))
diff --git a/web/app/components/header/nav/nav-selector/index.tsx b/web/app/components/header/nav/nav-selector/index.tsx
index e66837c06c..264bfc0ffb 100644
--- a/web/app/components/header/nav/nav-selector/index.tsx
+++ b/web/app/components/header/nav/nav-selector/index.tsx
@@ -7,7 +7,6 @@ import {
RiArrowRightSLine,
} from '@remixicon/react'
import { debounce } from 'es-toolkit/compat'
-import { useRouter } from 'next/navigation'
import { Fragment, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
@@ -16,6 +15,7 @@ import AppIcon from '@/app/components/base/app-icon'
import { FileArrow01, FilePlus01, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files'
import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
+import { useRouter } from '@/next/navigation'
import { cn } from '@/utils/classnames'
export type NavItem = {
diff --git a/web/app/components/header/plugins-nav/__tests__/index.spec.tsx b/web/app/components/header/plugins-nav/__tests__/index.spec.tsx
index 009e573eb1..ab55225641 100644
--- a/web/app/components/header/plugins-nav/__tests__/index.spec.tsx
+++ b/web/app/components/header/plugins-nav/__tests__/index.spec.tsx
@@ -1,11 +1,11 @@
import type { Mock } from 'vitest'
import { render, screen } from '@testing-library/react'
-import { useSelectedLayoutSegment } from 'next/navigation'
import { usePluginTaskStatus } from '@/app/components/plugins/plugin-page/plugin-tasks/hooks'
+import { useSelectedLayoutSegment } from '@/next/navigation'
import PluginsNav from '../index'
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useSelectedLayoutSegment: vi.fn(),
}))
diff --git a/web/app/components/header/plugins-nav/index.tsx b/web/app/components/header/plugins-nav/index.tsx
index 5501048915..c77eb4d57e 100644
--- a/web/app/components/header/plugins-nav/index.tsx
+++ b/web/app/components/header/plugins-nav/index.tsx
@@ -1,11 +1,11 @@
'use client'
-import { useSelectedLayoutSegment } from 'next/navigation'
import { useTranslation } from 'react-i18next'
import { Group } from '@/app/components/base/icons/src/vender/other'
import Indicator from '@/app/components/header/indicator'
import { usePluginTaskStatus } from '@/app/components/plugins/plugin-page/plugin-tasks/hooks'
import Link from '@/next/link'
+import { useSelectedLayoutSegment } from '@/next/navigation'
import { cn } from '@/utils/classnames'
import DownloadingIcon from './downloading-icon'
diff --git a/web/app/components/header/tools-nav/__tests__/index.spec.tsx b/web/app/components/header/tools-nav/__tests__/index.spec.tsx
index e3ceef43a4..361e6f8b84 100644
--- a/web/app/components/header/tools-nav/__tests__/index.spec.tsx
+++ b/web/app/components/header/tools-nav/__tests__/index.spec.tsx
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import ToolsNav from '../index'
const mockUseSelectedLayoutSegment = vi.fn()
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useSelectedLayoutSegment: () => mockUseSelectedLayoutSegment(),
}))
diff --git a/web/app/components/header/tools-nav/index.tsx b/web/app/components/header/tools-nav/index.tsx
index d7abaa4680..141b576a4c 100644
--- a/web/app/components/header/tools-nav/index.tsx
+++ b/web/app/components/header/tools-nav/index.tsx
@@ -4,9 +4,9 @@ import {
RiHammerFill,
RiHammerLine,
} from '@remixicon/react'
-import { useSelectedLayoutSegment } from 'next/navigation'
import { useTranslation } from 'react-i18next'
import Link from '@/next/link'
+import { useSelectedLayoutSegment } from '@/next/navigation'
import { cn } from '@/utils/classnames'
type ToolsNavProps = {
diff --git a/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx b/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx
index 2bd20fb5c3..c7ddf4711e 100644
--- a/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx
+++ b/web/app/components/rag-pipeline/components/__tests__/conversion.spec.tsx
@@ -5,7 +5,7 @@ import Conversion from '../conversion'
const mockConvert = vi.fn()
const mockInvalidDatasetDetail = vi.fn()
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useParams: () => ({ datasetId: 'ds-123' }),
}))
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 8974965274..36454d33e4 100644
--- a/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx
+++ b/web/app/components/rag-pipeline/components/__tests__/index.spec.tsx
@@ -10,7 +10,7 @@ import RagPipelineChildren from '../rag-pipeline-children'
import PipelineScreenShot from '../screenshot'
const mockPush = vi.fn()
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useParams: () => ({ datasetId: 'test-dataset-id' }),
useRouter: () => ({ push: mockPush }),
}))
diff --git a/web/app/components/rag-pipeline/components/conversion.tsx b/web/app/components/rag-pipeline/components/conversion.tsx
index db3c04e9bf..b433359eeb 100644
--- a/web/app/components/rag-pipeline/components/conversion.tsx
+++ b/web/app/components/rag-pipeline/components/conversion.tsx
@@ -1,10 +1,10 @@
-import { useParams } from 'next/navigation'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Confirm from '@/app/components/base/confirm'
import Toast from '@/app/components/base/toast'
+import { useParams } from '@/next/navigation'
import { datasetDetailQueryKeyPrefix } from '@/service/knowledge/use-dataset'
import { useInvalid } from '@/service/use-base'
import { useConvertDatasetToPipeline } from '@/service/use-pipeline'
diff --git a/web/app/components/rag-pipeline/components/panel/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/panel/__tests__/index.spec.tsx
index f651b16697..8bf870a344 100644
--- a/web/app/components/rag-pipeline/components/panel/__tests__/index.spec.tsx
+++ b/web/app/components/rag-pipeline/components/panel/__tests__/index.spec.tsx
@@ -56,7 +56,7 @@ const { dynamicMocks, mockInputFieldEditorProps } = vi.hoisted(() => {
return { dynamicMocks: { createMockComponent }, mockInputFieldEditorProps }
})
-vi.mock('next/dynamic', () => ({
+vi.mock('@/next/dynamic', () => ({
default: (_loader: () => Promise<{ default: React.ComponentType }>, _options?: Record) => {
return dynamicMocks.createMockComponent()
},
diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx
index e1e6c2f7d5..f30db875b3 100644
--- a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx
+++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx
@@ -77,7 +77,7 @@ vi.mock('@/app/components/workflow/header', () => ({
}))
const mockPush = vi.fn()
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useParams: () => ({ datasetId: 'test-dataset-id' }),
useRouter: () => ({ push: mockPush }),
}))
diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx
index 345f3626ec..f0b187c0fd 100644
--- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx
+++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx
@@ -8,7 +8,7 @@ import Publisher from '../index'
import Popup from '../popup'
const mockPush = vi.fn()
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useParams: () => ({ datasetId: 'test-dataset-id' }),
useRouter: () => ({ push: mockPush }),
}))
diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx
index a0baac7785..5d053f083a 100644
--- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx
+++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx
@@ -19,7 +19,7 @@ let mockPublishedAt: string | undefined = '2024-01-01T00:00:00Z'
let mockDraftUpdatedAt: string | undefined = '2024-06-01T00:00:00Z'
let mockPipelineId: string | undefined = 'pipeline-123'
let mockIsAllowPublishAsCustom = true
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useParams: () => ({ datasetId: 'ds-123' }),
useRouter: () => ({ push: mockPush }),
}))
diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx
index 6d292de6a0..6670a8f767 100644
--- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx
+++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx
@@ -10,7 +10,6 @@ import {
useBoolean,
useKeyPress,
} from 'ahooks'
-import { useParams, useRouter } from 'next/navigation'
import {
memo,
useCallback,
@@ -40,6 +39,7 @@ import { useProviderContextSelector } from '@/context/provider-context'
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import Link from '@/next/link'
+import { useParams, useRouter } from '@/next/navigation'
import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
import { useInvalid } from '@/service/use-base'
import {
diff --git a/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx b/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx
index 46d229c6b6..7ccd788cb0 100644
--- a/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx
+++ b/web/app/components/share/text-generation/__tests__/menu-dropdown.spec.tsx
@@ -5,7 +5,7 @@ import MenuDropdown from '../menu-dropdown'
const mockReplace = vi.fn()
const mockPathname = '/test-path'
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: () => ({
replace: mockReplace,
}),
diff --git a/web/app/components/share/text-generation/index.tsx b/web/app/components/share/text-generation/index.tsx
index 779358bfc6..6162e2a87b 100644
--- a/web/app/components/share/text-generation/index.tsx
+++ b/web/app/components/share/text-generation/index.tsx
@@ -4,12 +4,12 @@ import type { InputValueTypes, TextGenerationRunControl } from './types'
import type { InstalledApp } from '@/models/explore'
import type { VisionFile } from '@/types/app'
import { useBoolean } from 'ahooks'
-import { useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
+import { useSearchParams } from '@/next/navigation'
import { cn } from '@/utils/classnames'
import { useTextGenerationAppState } from './hooks/use-text-generation-app-state'
import { useTextGenerationBatch } from './hooks/use-text-generation-batch'
diff --git a/web/app/components/share/text-generation/menu-dropdown.tsx b/web/app/components/share/text-generation/menu-dropdown.tsx
index 43856a0e24..49633890d3 100644
--- a/web/app/components/share/text-generation/menu-dropdown.tsx
+++ b/web/app/components/share/text-generation/menu-dropdown.tsx
@@ -5,7 +5,6 @@ import type { SiteInfo } from '@/models/share'
import {
RiEqualizer2Line,
} from '@remixicon/react'
-import { usePathname, useRouter } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -18,6 +17,7 @@ import {
import ThemeSwitcher from '@/app/components/base/theme-switcher'
import { useWebAppStore } from '@/context/web-app-context'
import { AccessMode } from '@/models/access-control'
+import { usePathname, useRouter } from '@/next/navigation'
import { webAppLogout } from '@/service/webapp-auth'
import { cn } from '@/utils/classnames'
import Divider from '../../base/divider'
diff --git a/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx b/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx
index e3bdb4e58a..9cd66e37ea 100644
--- a/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx
+++ b/web/app/components/tools/workflow-tool/__tests__/configure-button.spec.tsx
@@ -11,7 +11,7 @@ import MethodSelector from '../method-selector'
// Mock Next.js navigation
const mockPush = vi.fn()
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,
replace: vi.fn(),
diff --git a/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts b/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts
index cf685a7590..ad0dd2eff2 100644
--- a/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts
+++ b/web/app/components/tools/workflow-tool/hooks/__tests__/use-configure-button.spec.ts
@@ -5,7 +5,7 @@ import { InputVarType } from '@/app/components/workflow/types'
import { isParametersOutdated, useConfigureButton } from '../use-configure-button'
const mockPush = vi.fn()
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}))
diff --git a/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts b/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts
index 1aa968ddb1..701ae8fd01 100644
--- a/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts
+++ b/web/app/components/tools/workflow-tool/hooks/use-configure-button.ts
@@ -1,11 +1,11 @@
import type { Emoji, WorkflowToolProviderOutputParameter, WorkflowToolProviderParameter, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '@/app/components/tools/types'
import type { InputVar, Variable } from '@/app/components/workflow/types'
import type { PublishWorkflowParams } from '@/types/workflow'
-import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { useAppContext } from '@/context/app-context'
+import { useRouter } from '@/next/navigation'
import { createWorkflowToolProvider, saveWorkflowToolProvider } from '@/service/tools'
import { useInvalidateAllWorkflowTools, useInvalidateWorkflowToolDetailByAppID, useWorkflowToolDetailByAppID } from '@/service/use-tools'
diff --git a/web/app/components/workflow-app/hooks/use-workflow-run.ts b/web/app/components/workflow-app/hooks/use-workflow-run.ts
index ae4a21f5a0..de110f2525 100644
--- a/web/app/components/workflow-app/hooks/use-workflow-run.ts
+++ b/web/app/components/workflow-app/hooks/use-workflow-run.ts
@@ -4,7 +4,6 @@ import type { IOtherOptions } from '@/service/base'
import type { VersionHistory } from '@/types/workflow'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer'
-import { usePathname } from 'next/navigation'
import { useCallback, useRef } from 'react'
import {
useReactFlow,
@@ -21,6 +20,7 @@ import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-
import { useWorkflowRunEvent } from '@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
+import { usePathname } from '@/next/navigation'
import { handleStream, post, sseGet, ssePost } from '@/service/base'
import { ContentType } from '@/service/fetch'
import { useInvalidAllLastRun, useInvalidateWorkflowRunHistory } from '@/service/use-workflow'
diff --git a/web/app/components/workflow-app/index.tsx b/web/app/components/workflow-app/index.tsx
index 6a778ab6b8..0e8731869f 100644
--- a/web/app/components/workflow-app/index.tsx
+++ b/web/app/components/workflow-app/index.tsx
@@ -2,7 +2,6 @@
import type { Features as FeaturesData } from '@/app/components/base/features/types'
import type { InjectWorkflowStoreSliceFn } from '@/app/components/workflow/store'
-import { useSearchParams } from 'next/navigation'
import {
useEffect,
useMemo,
@@ -25,6 +24,7 @@ import {
initialNodes,
} from '@/app/components/workflow/utils'
import { useAppContext } from '@/context/app-context'
+import { useSearchParams } from '@/next/navigation'
import { fetchRunDetail } from '@/service/log'
import { useAppTriggers } from '@/service/use-tools'
import { AppModeEnum } from '@/types/app'
diff --git a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx
index 1e40ea65da..44bd1ea775 100644
--- a/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx
+++ b/web/app/components/workflow/__tests__/workflow-edge-events.spec.tsx
@@ -80,7 +80,7 @@ function createMouseEvent() {
} as unknown as React.MouseEvent
}
-vi.mock('next/dynamic', () => ({
+vi.mock('@/next/dynamic', () => ({
default: () => () => null,
}))
diff --git a/web/app/components/workflow/header/index.tsx b/web/app/components/workflow/header/index.tsx
index bf7479b198..5e6b714213 100644
--- a/web/app/components/workflow/header/index.tsx
+++ b/web/app/components/workflow/header/index.tsx
@@ -1,8 +1,8 @@
import type { HeaderInNormalProps } from './header-in-normal'
import type { HeaderInRestoringProps } from './header-in-restoring'
import type { HeaderInHistoryProps } from './header-in-view-history'
-import { usePathname } from 'next/navigation'
import dynamic from '@/next/dynamic'
+import { usePathname } from '@/next/navigation'
import {
useWorkflowMode,
} from '../hooks'
diff --git a/web/app/education-apply/education-apply-page.tsx b/web/app/education-apply/education-apply-page.tsx
index d457409d29..19ef26814d 100644
--- a/web/app/education-apply/education-apply-page.tsx
+++ b/web/app/education-apply/education-apply-page.tsx
@@ -2,10 +2,6 @@
import { RiExternalLinkLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
-import {
- useRouter,
- useSearchParams,
-} from 'next/navigation'
import {
useState,
} from 'react'
@@ -16,6 +12,10 @@ import { useToastContext } from '@/app/components/base/toast/context'
import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants'
import { useDocLink } from '@/context/i18n'
import { useProviderContext } from '@/context/provider-context'
+import {
+ useRouter,
+ useSearchParams,
+} from '@/next/navigation'
import {
useEducationAdd,
useInvalidateEducationStatus,
diff --git a/web/app/education-apply/expire-notice-modal.tsx b/web/app/education-apply/expire-notice-modal.tsx
index c44ee0f386..fc939ffc3c 100644
--- a/web/app/education-apply/expire-notice-modal.tsx
+++ b/web/app/education-apply/expire-notice-modal.tsx
@@ -1,6 +1,5 @@
'use client'
import { RiExternalLinkLine } from '@remixicon/react'
-import { useRouter } from 'next/navigation'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
@@ -9,6 +8,7 @@ import { useDocLink } from '@/context/i18n'
import { useModalContextSelector } from '@/context/modal-context'
import useTimestamp from '@/hooks/use-timestamp'
import Link from '@/next/link'
+import { useRouter } from '@/next/navigation'
import { useEducationVerify } from '@/service/use-education'
import { SparklesSoftAccent } from '../components/base/icons/src/public/common'
diff --git a/web/app/education-apply/hooks.ts b/web/app/education-apply/hooks.ts
index 52acde2975..79faa8b3b2 100644
--- a/web/app/education-apply/hooks.ts
+++ b/web/app/education-apply/hooks.ts
@@ -3,7 +3,6 @@ import { useDebounceFn, useLocalStorageState } from 'ahooks'
import dayjs from 'dayjs'
import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
-import { useRouter, useSearchParams } from 'next/navigation'
import {
useCallback,
useEffect,
@@ -13,6 +12,7 @@ import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/con
import { useAppContext } from '@/context/app-context'
import { useModalContextSelector } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
+import { useRouter, useSearchParams } from '@/next/navigation'
import { useEducationAutocomplete, useEducationVerify } from '@/service/use-education'
import {
EDUCATION_RE_VERIFY_ACTION,
diff --git a/web/app/education-apply/user-info.tsx b/web/app/education-apply/user-info.tsx
index 6481194870..dc10af7e3c 100644
--- a/web/app/education-apply/user-info.tsx
+++ b/web/app/education-apply/user-info.tsx
@@ -1,9 +1,9 @@
-import { useRouter } from 'next/navigation'
import { useTranslation } from 'react-i18next'
import { Avatar } from '@/app/components/base/avatar'
import Button from '@/app/components/base/button'
import { Triangle } from '@/app/components/base/icons/src/public/education'
import { useAppContext } from '@/context/app-context'
+import { useRouter } from '@/next/navigation'
import { useLogout } from '@/service/use-common'
const UserInfo = () => {
diff --git a/web/app/forgot-password/ChangePasswordForm.tsx b/web/app/forgot-password/ChangePasswordForm.tsx
index 880b010d9c..00f61cab2c 100644
--- a/web/app/forgot-password/ChangePasswordForm.tsx
+++ b/web/app/forgot-password/ChangePasswordForm.tsx
@@ -1,12 +1,12 @@
'use client'
import { CheckCircleIcon } from '@heroicons/react/24/solid'
-import { useSearchParams } from 'next/navigation'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast'
import { validPassword } from '@/config'
+import { useSearchParams } from '@/next/navigation'
import { changePasswordWithToken } from '@/service/common'
import { useVerifyForgotPasswordToken } from '@/service/use-common'
import { cn } from '@/utils/classnames'
diff --git a/web/app/forgot-password/ForgotPasswordForm.spec.tsx b/web/app/forgot-password/ForgotPasswordForm.spec.tsx
index aa360cb6c3..8ed120d146 100644
--- a/web/app/forgot-password/ForgotPasswordForm.spec.tsx
+++ b/web/app/forgot-password/ForgotPasswordForm.spec.tsx
@@ -5,7 +5,7 @@ import ForgotPasswordForm from './ForgotPasswordForm'
const mockPush = vi.fn()
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}))
diff --git a/web/app/forgot-password/ForgotPasswordForm.tsx b/web/app/forgot-password/ForgotPasswordForm.tsx
index 274c2fd4e6..fdc35c20da 100644
--- a/web/app/forgot-password/ForgotPasswordForm.tsx
+++ b/web/app/forgot-password/ForgotPasswordForm.tsx
@@ -2,15 +2,15 @@
import type { InitValidateStatusResponse } from '@/models/common'
import { useStore } from '@tanstack/react-form'
-import { useRouter } from 'next/navigation'
-
import * as React from 'react'
+
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import * as z from 'zod'
import Button from '@/app/components/base/button'
import { formContext, useAppForm } from '@/app/components/base/form'
import { zodSubmitValidator } from '@/app/components/base/form/utils/zod-submit-validator'
+import { useRouter } from '@/next/navigation'
import {
fetchInitValidateStatus,
fetchSetupStatus,
diff --git a/web/app/forgot-password/page.tsx b/web/app/forgot-password/page.tsx
index 338f4eaf13..7014b9e5b6 100644
--- a/web/app/forgot-password/page.tsx
+++ b/web/app/forgot-password/page.tsx
@@ -1,9 +1,9 @@
'use client'
-import { useSearchParams } from 'next/navigation'
import * as React from 'react'
import ChangePasswordForm from '@/app/forgot-password/ChangePasswordForm'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useDocumentTitle from '@/hooks/use-document-title'
+import { useSearchParams } from '@/next/navigation'
import { cn } from '@/utils/classnames'
import Header from '../signin/_header'
import ForgotPasswordForm from './ForgotPasswordForm'
diff --git a/web/app/init/InitPasswordPopup.tsx b/web/app/init/InitPasswordPopup.tsx
index c8598881a3..d2ec3c7e2b 100644
--- a/web/app/init/InitPasswordPopup.tsx
+++ b/web/app/init/InitPasswordPopup.tsx
@@ -1,10 +1,10 @@
'use client'
import type { InitValidateStatusResponse } from '@/models/common'
-import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import useDocumentTitle from '@/hooks/use-document-title'
+import { useRouter } from '@/next/navigation'
import { fetchInitValidateStatus, initValidate } from '@/service/common'
import { basePath } from '@/utils/var'
import Loading from '../components/base/loading'
diff --git a/web/app/install/installForm.spec.tsx b/web/app/install/installForm.spec.tsx
index 17ce35d6a1..1286d02343 100644
--- a/web/app/install/installForm.spec.tsx
+++ b/web/app/install/installForm.spec.tsx
@@ -7,7 +7,7 @@ import InstallForm from './installForm'
const mockPush = vi.fn()
const mockReplace = vi.fn()
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: mockPush, replace: mockReplace }),
}))
diff --git a/web/app/install/installForm.tsx b/web/app/install/installForm.tsx
index 28c9f0e702..292a922723 100644
--- a/web/app/install/installForm.tsx
+++ b/web/app/install/installForm.tsx
@@ -1,9 +1,8 @@
'use client'
import type { InitValidateStatusResponse, SetupStatusResponse } from '@/models/common'
import { useStore } from '@tanstack/react-form'
-import { useRouter } from 'next/navigation'
-
import * as React from 'react'
+
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import * as z from 'zod'
@@ -13,9 +12,10 @@ import { zodSubmitValidator } from '@/app/components/base/form/utils/zod-submit-
import Input from '@/app/components/base/input'
import { validPassword } from '@/config'
import { LICENSE_LINK } from '@/constants/link'
-
import useDocumentTitle from '@/hooks/use-document-title'
+
import Link from '@/next/link'
+import { useRouter } from '@/next/navigation'
import { fetchInitValidateStatus, fetchSetupStatus, login, setup } from '@/service/common'
import { cn } from '@/utils/classnames'
import { encryptPassword as encodePassword } from '@/utils/encryption'
diff --git a/web/app/reset-password/check-code/page.tsx b/web/app/reset-password/check-code/page.tsx
index cf4a6e6ce4..aac73b8e7d 100644
--- a/web/app/reset-password/check-code/page.tsx
+++ b/web/app/reset-password/check-code/page.tsx
@@ -1,6 +1,5 @@
'use client'
import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react'
-import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
@@ -8,6 +7,7 @@ import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import Countdown from '@/app/components/signin/countdown'
import { useLocale } from '@/context/i18n'
+import { useRouter, useSearchParams } from '@/next/navigation'
import { sendResetPasswordCode, verifyResetPasswordCode } from '@/service/common'
export default function CheckCode() {
diff --git a/web/app/reset-password/page.tsx b/web/app/reset-password/page.tsx
index 6fb399b8de..af9dc544a6 100644
--- a/web/app/reset-password/page.tsx
+++ b/web/app/reset-password/page.tsx
@@ -1,7 +1,6 @@
'use client'
import { RiArrowLeftLine, RiLockPasswordLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
-import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
@@ -11,6 +10,7 @@ import { emailRegex } from '@/config'
import { useLocale } from '@/context/i18n'
import useDocumentTitle from '@/hooks/use-document-title'
import Link from '@/next/link'
+import { useRouter, useSearchParams } from '@/next/navigation'
import { sendResetPasswordCode } from '@/service/common'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '../components/signin/countdown'
diff --git a/web/app/reset-password/set-password/page.tsx b/web/app/reset-password/set-password/page.tsx
index bf7947c79d..e187bb28cb 100644
--- a/web/app/reset-password/set-password/page.tsx
+++ b/web/app/reset-password/set-password/page.tsx
@@ -1,13 +1,13 @@
'use client'
import { RiCheckboxCircleFill } from '@remixicon/react'
import { useCountDown } from 'ahooks'
-import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, 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/toast'
import { validPassword } from '@/config'
+import { useRouter, useSearchParams } from '@/next/navigation'
import { changePasswordWithToken } from '@/service/common'
import { cn } from '@/utils/classnames'
diff --git a/web/app/routePrefixHandle.tsx b/web/app/routePrefixHandle.tsx
index d3a36a51fc..e772c7964a 100644
--- a/web/app/routePrefixHandle.tsx
+++ b/web/app/routePrefixHandle.tsx
@@ -1,7 +1,7 @@
'use client'
-import { usePathname } from 'next/navigation'
import { useEffect } from 'react'
+import { usePathname } from '@/next/navigation'
import { basePath } from '@/utils/var'
export default function RoutePrefixHandle() {
diff --git a/web/app/signin/check-code/page.tsx b/web/app/signin/check-code/page.tsx
index 24ac92157e..dfd346e502 100644
--- a/web/app/signin/check-code/page.tsx
+++ b/web/app/signin/check-code/page.tsx
@@ -1,7 +1,6 @@
'use client'
import type { FormEvent } from 'react'
import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react'
-import { useRouter, useSearchParams } from 'next/navigation'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
@@ -9,8 +8,9 @@ import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import Countdown from '@/app/components/signin/countdown'
-
import { useLocale } from '@/context/i18n'
+
+import { useRouter, useSearchParams } from '@/next/navigation'
import { emailLoginWithCode, sendEMailLoginCode } from '@/service/common'
import { encryptVerificationCode } from '@/utils/encryption'
import { resolvePostLoginRedirect } from '../utils/post-login-redirect'
diff --git a/web/app/signin/components/mail-and-code-auth.tsx b/web/app/signin/components/mail-and-code-auth.tsx
index 4454fc821f..86fc0db36b 100644
--- a/web/app/signin/components/mail-and-code-auth.tsx
+++ b/web/app/signin/components/mail-and-code-auth.tsx
@@ -1,5 +1,4 @@
import type { FormEvent } from 'react'
-import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
@@ -8,6 +7,7 @@ import Toast from '@/app/components/base/toast'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { emailRegex } from '@/config'
import { useLocale } from '@/context/i18n'
+import { useRouter, useSearchParams } from '@/next/navigation'
import { sendEMailLoginCode } from '@/service/common'
type MailAndCodeAuthProps = {
diff --git a/web/app/signin/components/mail-and-password-auth.tsx b/web/app/signin/components/mail-and-password-auth.tsx
index 0ec7cd8a29..7ce4c9054f 100644
--- a/web/app/signin/components/mail-and-password-auth.tsx
+++ b/web/app/signin/components/mail-and-password-auth.tsx
@@ -1,6 +1,5 @@
import type { ResponseError } from '@/service/fetch'
import { noop } from 'es-toolkit/function'
-import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
@@ -10,6 +9,7 @@ import Toast from '@/app/components/base/toast'
import { emailRegex } from '@/config'
import { useLocale } from '@/context/i18n'
import Link from '@/next/link'
+import { useRouter, useSearchParams } from '@/next/navigation'
import { login } from '@/service/common'
import { setWebAppAccessToken } from '@/service/webapp-auth'
import { encryptPassword } from '@/utils/encryption'
diff --git a/web/app/signin/components/social-auth.tsx b/web/app/signin/components/social-auth.tsx
index 8a610bf093..35517a0505 100644
--- a/web/app/signin/components/social-auth.tsx
+++ b/web/app/signin/components/social-auth.tsx
@@ -1,7 +1,7 @@
-import { useSearchParams } from 'next/navigation'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { API_PREFIX } from '@/config'
+import { useSearchParams } from '@/next/navigation'
import { getPurifyHref } from '@/utils'
import { cn } from '@/utils/classnames'
import style from '../page.module.css'
diff --git a/web/app/signin/components/sso-auth.tsx b/web/app/signin/components/sso-auth.tsx
index 43d5d2dfe8..904403ab2c 100644
--- a/web/app/signin/components/sso-auth.tsx
+++ b/web/app/signin/components/sso-auth.tsx
@@ -1,11 +1,11 @@
'use client'
import type { FC } from 'react'
-import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security'
import Toast from '@/app/components/base/toast'
+import { useRouter, useSearchParams } from '@/next/navigation'
import { getUserOAuth2SSOUrl, getUserOIDCSSOUrl, getUserSAMLSSOUrl } from '@/service/sso'
import { SSOProtocol } from '@/types/feature'
diff --git a/web/app/signin/invite-settings/page.tsx b/web/app/signin/invite-settings/page.tsx
index a8d43d74c4..ac7a7191f8 100644
--- a/web/app/signin/invite-settings/page.tsx
+++ b/web/app/signin/invite-settings/page.tsx
@@ -2,7 +2,6 @@
import type { Locale } from '@/i18n-config'
import { RiAccountCircleLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
-import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
@@ -15,6 +14,7 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
import { setLocaleOnClient } from '@/i18n-config'
import { languages, LanguagesSupported } from '@/i18n-config/language'
import Link from '@/next/link'
+import { useRouter, useSearchParams } from '@/next/navigation'
import { activateMember } from '@/service/common'
import { useInvitationCheck } from '@/service/use-common'
import { timezones } from '@/utils/timezone'
diff --git a/web/app/signin/normal-form.tsx b/web/app/signin/normal-form.tsx
index 314da7616f..1916dd6d1c 100644
--- a/web/app/signin/normal-form.tsx
+++ b/web/app/signin/normal-form.tsx
@@ -1,5 +1,4 @@
import { RiContractLine, RiDoorLockLine, RiErrorWarningFill } from '@remixicon/react'
-import { useRouter, useSearchParams } from 'next/navigation'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -7,6 +6,7 @@ import Toast from '@/app/components/base/toast'
import { IS_CE_EDITION } from '@/config'
import { useGlobalPublicStore } from '@/context/global-public-context'
import Link from '@/next/link'
+import { useRouter, useSearchParams } from '@/next/navigation'
import { invitationCheck } from '@/service/common'
import { useIsLogin } from '@/service/use-common'
import { LicenseStatus } from '@/types/feature'
diff --git a/web/app/signin/one-more-step.tsx b/web/app/signin/one-more-step.tsx
index 099f5d9c0b..1d632e272c 100644
--- a/web/app/signin/one-more-step.tsx
+++ b/web/app/signin/one-more-step.tsx
@@ -1,6 +1,5 @@
'use client'
import type { Reducer } from 'react'
-import { useRouter, useSearchParams } from 'next/navigation'
import { useReducer } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
@@ -10,6 +9,7 @@ import Tooltip from '@/app/components/base/tooltip'
import { LICENSE_LINK } from '@/constants/link'
import { languages, LanguagesSupported } from '@/i18n-config/language'
import Link from '@/next/link'
+import { useRouter, useSearchParams } from '@/next/navigation'
import { useOneMoreStep } from '@/service/use-common'
import { timezones } from '@/utils/timezone'
import Input from '../components/base/input'
diff --git a/web/app/signin/page.tsx b/web/app/signin/page.tsx
index 6f3632393c..7fad92fe5d 100644
--- a/web/app/signin/page.tsx
+++ b/web/app/signin/page.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useSearchParams } from 'next/navigation'
import { useEffect } from 'react'
+import { useSearchParams } from '@/next/navigation'
import usePSInfo from '../components/billing/partner-stack/use-ps-info'
import NormalForm from './normal-form'
import OneMoreStep from './one-more-step'
diff --git a/web/app/signup/check-code/page.tsx b/web/app/signup/check-code/page.tsx
index c298c11535..00abc280f8 100644
--- a/web/app/signup/check-code/page.tsx
+++ b/web/app/signup/check-code/page.tsx
@@ -1,7 +1,6 @@
'use client'
import type { MailSendResponse, MailValidityResponse } from '@/service/use-common'
import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react'
-import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
@@ -9,6 +8,7 @@ import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import Countdown from '@/app/components/signin/countdown'
import { useLocale } from '@/context/i18n'
+import { useRouter, useSearchParams } from '@/next/navigation'
import { useMailValidity, useSendMail } from '@/service/use-common'
export default function CheckCode() {
diff --git a/web/app/signup/page.tsx b/web/app/signup/page.tsx
index a5a8fb40a7..da821ae50e 100644
--- a/web/app/signup/page.tsx
+++ b/web/app/signup/page.tsx
@@ -1,7 +1,7 @@
'use client'
-import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
+import { useRouter, useSearchParams } from '@/next/navigation'
import MailForm from './components/input-mail'
const Signup = () => {
diff --git a/web/app/signup/set-password/page.tsx b/web/app/signup/set-password/page.tsx
index 69af045f1a..c38fe68803 100644
--- a/web/app/signup/set-password/page.tsx
+++ b/web/app/signup/set-password/page.tsx
@@ -1,7 +1,6 @@
'use client'
import type { MailRegisterResponse } from '@/service/use-common'
import Cookies from 'js-cookie'
-import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
@@ -9,6 +8,7 @@ import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import { validPassword } from '@/config'
+import { useRouter, useSearchParams } from '@/next/navigation'
import { useMailRegister } from '@/service/use-common'
import { cn } from '@/utils/classnames'
import { sendGAEvent } from '@/utils/gtag'
diff --git a/web/context/modal-context.test.tsx b/web/context/modal-context.test.tsx
index 98f67a5473..6fac2e0cd5 100644
--- a/web/context/modal-context.test.tsx
+++ b/web/context/modal-context.test.tsx
@@ -13,7 +13,7 @@ vi.mock('@/config', async (importOriginal) => {
}
})
-vi.mock('next/navigation', () => ({
+vi.mock('@/next/navigation', () => ({
useSearchParams: vi.fn(() => new URLSearchParams()),
}))
diff --git a/web/context/web-app-context.tsx b/web/context/web-app-context.tsx
index c5488a565c..33679fd44f 100644
--- a/web/context/web-app-context.tsx
+++ b/web/context/web-app-context.tsx
@@ -3,12 +3,12 @@
import type { FC, PropsWithChildren } from 'react'
import type { ChatConfig } from '@/app/components/base/chat/types'
import type { AppData, AppMeta } from '@/models/share'
-import { usePathname, useSearchParams } from 'next/navigation'
import { useEffect } from 'react'
import { create } from 'zustand'
import { getProcessedSystemVariablesFromUrlParams } from '@/app/components/base/chat/utils'
import Loading from '@/app/components/base/loading'
import { AccessMode } from '@/models/access-control'
+import { usePathname, useSearchParams } from '@/next/navigation'
import { useGetWebAppAccessModeByCode } from '@/service/use-share'
import { useIsSystemFeaturesPending } from './global-public-context'
diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs
index 778e81866b..0c7a2554e3 100644
--- a/web/eslint.config.mjs
+++ b/web/eslint.config.mjs
@@ -17,7 +17,7 @@ const disableRuleAutoFix = !(isInEditorEnv() || isInGitHooksOrLintStaged())
const NEXT_PLATFORM_RESTRICTED_IMPORT_PATHS = [
{
name: 'next',
- message: 'Import Next APIs from @/next instead of next.',
+ message: 'Import Next APIs from the corresponding @/next module instead of next.',
},
]
@@ -31,24 +31,8 @@ const NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS = [
message: 'Do not import next/font. Use the project font styles instead.',
},
{
- group: ['next/dynamic'],
- message: 'Import Next APIs from @/next/dynamic instead of next/dynamic.',
- },
- {
- group: ['next/headers'],
- message: 'Import Next APIs from @/next/headers instead of next/headers.',
- },
- {
- group: ['next/script'],
- message: 'Import Next APIs from @/next/script instead of next/script.',
- },
- {
- group: ['next/server'],
- message: 'Import Next APIs from @/next/server instead of next/server.',
- },
- {
- group: ['next/link'],
- message: 'Import Next APIs from @/next/link instead of next/link.',
+ group: ['next/*', '!next/font', '!next/font/*', '!next/image', '!next/image/*'],
+ message: 'Import Next APIs from the corresponding @/next/* module instead of next/*.',
},
]
diff --git a/web/hooks/use-import-dsl.ts b/web/hooks/use-import-dsl.ts
index 454f580b42..903fd74c5b 100644
--- a/web/hooks/use-import-dsl.ts
+++ b/web/hooks/use-import-dsl.ts
@@ -3,7 +3,6 @@ import type {
DSLImportResponse,
} from '@/models/app'
import type { AppIconType } from '@/types/app'
-import { useRouter } from 'next/navigation'
import {
useCallback,
useRef,
@@ -15,6 +14,7 @@ import { usePluginDependencies } from '@/app/components/workflow/plugin-dependen
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useSelector } from '@/context/app-context'
import { DSLImportStatus } from '@/models/app'
+import { useRouter } from '@/next/navigation'
import {
importDSL,
importDSLConfirm,
diff --git a/web/hooks/use-pay.tsx b/web/hooks/use-pay.tsx
index a72107daeb..5ce50fdb0f 100644
--- a/web/hooks/use-pay.tsx
+++ b/web/hooks/use-pay.tsx
@@ -1,10 +1,10 @@
'use client'
import type { IConfirm } from '@/app/components/base/confirm'
-import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
+import { useRouter, useSearchParams } from '@/next/navigation'
import { useNotionBinding } from '@/service/use-common'
export type ConfirmType = Pick
diff --git a/web/next/navigation.ts b/web/next/navigation.ts
new file mode 100644
index 0000000000..ec7c112645
--- /dev/null
+++ b/web/next/navigation.ts
@@ -0,0 +1,8 @@
+export {
+ useParams,
+ usePathname,
+ useRouter,
+ useSearchParams,
+ useSelectedLayoutSegment,
+ useSelectedLayoutSegments,
+} from 'next/navigation'
From dc69f65b4b82debb396bd175f0e0303ba732e5a1 Mon Sep 17 00:00:00 2001
From: FFXN <31929997+FFXN@users.noreply.github.com>
Date: Wed, 18 Mar 2026 13:31:45 +0800
Subject: [PATCH 05/36] fix: add responding error information when obtain
pipeline template detail failed (#33628)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
---
.../datasets/rag_pipeline/rag_pipeline.py | 2 +
.../remote/remote_retrieval.py | 17 ++++++---
api/services/rag_pipeline/rag_pipeline.py | 10 ++++-
.../rag_pipeline/test_rag_pipeline.py | 38 +++++++++++++++++++
4 files changed, 61 insertions(+), 6 deletions(-)
diff --git a/api/controllers/console/datasets/rag_pipeline/rag_pipeline.py b/api/controllers/console/datasets/rag_pipeline/rag_pipeline.py
index 6e0cd31b8d..4f31093cfe 100644
--- a/api/controllers/console/datasets/rag_pipeline/rag_pipeline.py
+++ b/api/controllers/console/datasets/rag_pipeline/rag_pipeline.py
@@ -46,6 +46,8 @@ class PipelineTemplateDetailApi(Resource):
type = request.args.get("type", default="built-in", type=str)
rag_pipeline_service = RagPipelineService()
pipeline_template = rag_pipeline_service.get_pipeline_template_detail(template_id, type)
+ if pipeline_template is None:
+ return {"error": "Pipeline template not found from upstream service."}, 404
return pipeline_template, 200
diff --git a/api/services/rag_pipeline/pipeline_template/remote/remote_retrieval.py b/api/services/rag_pipeline/pipeline_template/remote/remote_retrieval.py
index 571ca6c7a6..f996db11dc 100644
--- a/api/services/rag_pipeline/pipeline_template/remote/remote_retrieval.py
+++ b/api/services/rag_pipeline/pipeline_template/remote/remote_retrieval.py
@@ -15,7 +15,8 @@ class RemotePipelineTemplateRetrieval(PipelineTemplateRetrievalBase):
Retrieval recommended app from dify official
"""
- def get_pipeline_template_detail(self, template_id: str):
+ def get_pipeline_template_detail(self, template_id: str) -> dict | None:
+ result: dict | None
try:
result = self.fetch_pipeline_template_detail_from_dify_official(template_id)
except Exception as e:
@@ -35,17 +36,23 @@ class RemotePipelineTemplateRetrieval(PipelineTemplateRetrievalBase):
return PipelineTemplateType.REMOTE
@classmethod
- def fetch_pipeline_template_detail_from_dify_official(cls, template_id: str) -> dict | None:
+ def fetch_pipeline_template_detail_from_dify_official(cls, template_id: str) -> dict:
"""
Fetch pipeline template detail from dify official.
- :param template_id: Pipeline ID
- :return:
+
+ :param template_id: Pipeline template ID
+ :return: Template detail dict
+ :raises ValueError: When upstream returns a non-200 status code
"""
domain = dify_config.HOSTED_FETCH_PIPELINE_TEMPLATES_REMOTE_DOMAIN
url = f"{domain}/pipeline-templates/{template_id}"
response = httpx.get(url, timeout=httpx.Timeout(10.0, connect=3.0))
if response.status_code != 200:
- return None
+ raise ValueError(
+ "fetch pipeline template detail failed,"
+ + f" status_code: {response.status_code},"
+ + f" response: {response.text[:1000]}"
+ )
data: dict = response.json()
return data
diff --git a/api/services/rag_pipeline/rag_pipeline.py b/api/services/rag_pipeline/rag_pipeline.py
index ecee562c93..f3aedafac9 100644
--- a/api/services/rag_pipeline/rag_pipeline.py
+++ b/api/services/rag_pipeline/rag_pipeline.py
@@ -117,13 +117,21 @@ class RagPipelineService:
def get_pipeline_template_detail(cls, template_id: str, type: str = "built-in") -> dict | None:
"""
Get pipeline template detail.
+
:param template_id: template id
- :return:
+ :param type: template type, "built-in" or "customized"
+ :return: template detail dict, or None if not found
"""
if type == "built-in":
mode = dify_config.HOSTED_FETCH_PIPELINE_TEMPLATES_MODE
retrieval_instance = PipelineTemplateRetrievalFactory.get_pipeline_template_factory(mode)()
built_in_result: dict | None = retrieval_instance.get_pipeline_template_detail(template_id)
+ if built_in_result is None:
+ logger.warning(
+ "pipeline template retrieval returned empty result, template_id: %s, mode: %s",
+ template_id,
+ mode,
+ )
return built_in_result
else:
mode = "customized"
diff --git a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline.py b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline.py
index 3b8679f4ec..ebbb34e069 100644
--- a/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline.py
+++ b/api/tests/unit_tests/controllers/console/datasets/rag_pipeline/test_rag_pipeline.py
@@ -59,6 +59,44 @@ class TestPipelineTemplateDetailApi:
assert status == 200
assert response == template
+ def test_get_returns_404_when_template_not_found(self, app):
+ api = PipelineTemplateDetailApi()
+ method = unwrap(api.get)
+
+ service = MagicMock()
+ service.get_pipeline_template_detail.return_value = None
+
+ with (
+ app.test_request_context("/?type=built-in"),
+ patch(
+ "controllers.console.datasets.rag_pipeline.rag_pipeline.RagPipelineService",
+ return_value=service,
+ ),
+ ):
+ response, status = method(api, "non-existent-id")
+
+ assert status == 404
+ assert "error" in response
+
+ def test_get_returns_404_for_customized_type_not_found(self, app):
+ api = PipelineTemplateDetailApi()
+ method = unwrap(api.get)
+
+ service = MagicMock()
+ service.get_pipeline_template_detail.return_value = None
+
+ with (
+ app.test_request_context("/?type=customized"),
+ patch(
+ "controllers.console.datasets.rag_pipeline.rag_pipeline.RagPipelineService",
+ return_value=service,
+ ),
+ ):
+ response, status = method(api, "non-existent-id")
+
+ assert status == 404
+ assert "error" in response
+
class TestCustomizedPipelineTemplateApi:
def test_patch_success(self, app):
From 116cc220190972523fb3bfa33dc9c47dea5f561f Mon Sep 17 00:00:00 2001
From: -LAN-
Date: Wed, 18 Mar 2026 14:28:33 +0800
Subject: [PATCH 06/36] fix: clarify webhook debug endpoint behavior (#33597)
---
api/controllers/trigger/webhook.py | 32 +++++++++++++++++--
.../controllers/trigger/test_webhook.py | 28 +++++++++++++++-
2 files changed, 57 insertions(+), 3 deletions(-)
diff --git a/api/controllers/trigger/webhook.py b/api/controllers/trigger/webhook.py
index 22b24271c6..eb579da5d4 100644
--- a/api/controllers/trigger/webhook.py
+++ b/api/controllers/trigger/webhook.py
@@ -70,7 +70,14 @@ def handle_webhook(webhook_id: str):
@bp.route("/webhook-debug/", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"])
def handle_webhook_debug(webhook_id: str):
- """Handle webhook debug calls without triggering production workflow execution."""
+ """Handle webhook debug calls without triggering production workflow execution.
+
+ The debug webhook endpoint is only for draft inspection flows. It never enqueues
+ Celery work for the published workflow; instead it dispatches an in-memory debug
+ event to an active Variable Inspector listener. Returning a clear error when no
+ listener is registered prevents a misleading 200 response for requests that are
+ effectively dropped.
+ """
try:
webhook_trigger, _, node_config, webhook_data, error = _prepare_webhook_execution(webhook_id, is_debug=True)
if error:
@@ -94,11 +101,32 @@ def handle_webhook_debug(webhook_id: str):
"method": webhook_data.get("method"),
},
)
- TriggerDebugEventBus.dispatch(
+ dispatch_count = TriggerDebugEventBus.dispatch(
tenant_id=webhook_trigger.tenant_id,
event=event,
pool_key=pool_key,
)
+ if dispatch_count == 0:
+ logger.warning(
+ "Webhook debug request dropped without an active listener for webhook %s (tenant=%s, app=%s, node=%s)",
+ webhook_trigger.webhook_id,
+ webhook_trigger.tenant_id,
+ webhook_trigger.app_id,
+ webhook_trigger.node_id,
+ )
+ return (
+ jsonify(
+ {
+ "error": "No active debug listener",
+ "message": (
+ "The webhook debug URL only works while the Variable Inspector is listening. "
+ "Use the published webhook URL to execute the workflow in Celery."
+ ),
+ "execution_url": webhook_trigger.webhook_url,
+ }
+ ),
+ 409,
+ )
response_data, status_code = WebhookService.generate_webhook_response(node_config)
return jsonify(response_data), status_code
diff --git a/api/tests/unit_tests/controllers/trigger/test_webhook.py b/api/tests/unit_tests/controllers/trigger/test_webhook.py
index d633365f2b..91c793d292 100644
--- a/api/tests/unit_tests/controllers/trigger/test_webhook.py
+++ b/api/tests/unit_tests/controllers/trigger/test_webhook.py
@@ -23,6 +23,7 @@ def mock_jsonify():
class DummyWebhookTrigger:
webhook_id = "wh-1"
+ webhook_url = "http://localhost:5001/triggers/webhook/wh-1"
tenant_id = "tenant-1"
app_id = "app-1"
node_id = "node-1"
@@ -104,7 +105,32 @@ class TestHandleWebhookDebug:
@patch.object(module.WebhookService, "get_webhook_trigger_and_workflow")
@patch.object(module.WebhookService, "extract_and_validate_webhook_data")
@patch.object(module.WebhookService, "build_workflow_inputs", return_value={"x": 1})
- @patch.object(module.TriggerDebugEventBus, "dispatch")
+ @patch.object(module.TriggerDebugEventBus, "dispatch", return_value=0)
+ def test_debug_requires_active_listener(
+ self,
+ mock_dispatch,
+ mock_build_inputs,
+ mock_extract,
+ mock_get,
+ ):
+ mock_get.return_value = (DummyWebhookTrigger(), None, "node_config")
+ mock_extract.return_value = {"method": "POST"}
+
+ response, status = module.handle_webhook_debug("wh-1")
+
+ assert status == 409
+ assert response["error"] == "No active debug listener"
+ assert response["message"] == (
+ "The webhook debug URL only works while the Variable Inspector is listening. "
+ "Use the published webhook URL to execute the workflow in Celery."
+ )
+ assert response["execution_url"] == DummyWebhookTrigger.webhook_url
+ mock_dispatch.assert_called_once()
+
+ @patch.object(module.WebhookService, "get_webhook_trigger_and_workflow")
+ @patch.object(module.WebhookService, "extract_and_validate_webhook_data")
+ @patch.object(module.WebhookService, "build_workflow_inputs", return_value={"x": 1})
+ @patch.object(module.TriggerDebugEventBus, "dispatch", return_value=1)
@patch.object(module.WebhookService, "generate_webhook_response")
def test_debug_success(
self,
From 387e5a345ff982afeb32a69a3f6463b07a11e806 Mon Sep 17 00:00:00 2001
From: wangxiaolei
Date: Wed, 18 Mar 2026 14:54:12 +0800
Subject: [PATCH 07/36] fix(api): make CreatorUserRole accept both `end-user`
and `end_user` (#33638)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
---
api/models/enums.py | 7 +++++++
.../models/test_enums_creator_user_role.py | 19 +++++++++++++++++++
2 files changed, 26 insertions(+)
create mode 100644 api/tests/unit_tests/models/test_enums_creator_user_role.py
diff --git a/api/models/enums.py b/api/models/enums.py
index 6af74cddc8..6499c5b443 100644
--- a/api/models/enums.py
+++ b/api/models/enums.py
@@ -11,6 +11,13 @@ class CreatorUserRole(StrEnum):
ACCOUNT = "account"
END_USER = "end_user"
+ @classmethod
+ def _missing_(cls, value):
+ if value == "end-user":
+ return cls.END_USER
+ else:
+ return super()._missing_(value)
+
class WorkflowRunTriggeredFrom(StrEnum):
DEBUGGING = "debugging"
diff --git a/api/tests/unit_tests/models/test_enums_creator_user_role.py b/api/tests/unit_tests/models/test_enums_creator_user_role.py
new file mode 100644
index 0000000000..6317166fdc
--- /dev/null
+++ b/api/tests/unit_tests/models/test_enums_creator_user_role.py
@@ -0,0 +1,19 @@
+import pytest
+
+from models.enums import CreatorUserRole
+
+
+def test_creator_user_role_missing_maps_hyphen_to_enum():
+ # given an alias with hyphen
+ value = "end-user"
+
+ # when converting to enum (invokes StrEnum._missing_ override)
+ role = CreatorUserRole(value)
+
+ # then it should map to END_USER
+ assert role is CreatorUserRole.END_USER
+
+
+def test_creator_user_role_missing_raises_for_unknown():
+ with pytest.raises(ValueError):
+ CreatorUserRole("unknown")
From db4deb1d6b6f166bbbb4758e08d7ad84c754705d Mon Sep 17 00:00:00 2001
From: Coding On Star <447357187@qq.com>
Date: Wed, 18 Mar 2026 16:40:28 +0800
Subject: [PATCH 08/36] test(workflow): reorganize specs into __tests__ and
align with shared test infrastructure (#33625)
Co-authored-by: CodingOnStar
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
---
.../__tests__/candidate-node.spec.tsx | 40 +++
.../__tests__/custom-connection-line.spec.tsx | 81 +++++
...ustom-edge-linear-gradient-render.spec.tsx | 57 ++++
.../dsl-export-confirm-modal.spec.tsx | 127 +++++++
.../workflow/__tests__/features.spec.tsx | 193 +++++++++++
.../__tests__/reactflow-mock-state.ts | 4 +-
.../__tests__/syncing-data-modal.spec.tsx | 22 ++
.../use-check-vertical-scrollbar.spec.ts | 108 ++++++
.../__tests__/use-sticky-scroll.spec.ts | 103 ++++++
.../block-selector/__tests__/utils.spec.ts | 108 ++++++
.../__tests__/view-type-select.spec.tsx | 57 ++++
.../use-check-vertical-scrollbar.ts | 2 +-
.../__tests__/chat-variable-button.spec.tsx | 59 ++++
.../header/__tests__/editing-title.spec.tsx | 63 ++++
.../header/__tests__/env-button.spec.tsx | 68 ++++
.../__tests__/global-variable-button.spec.tsx | 68 ++++
.../header/__tests__/restoring-title.spec.tsx | 109 ++++++
.../header/__tests__/running-title.spec.tsx | 61 ++++
.../scroll-to-selected-node-button.spec.tsx | 53 +++
.../header/__tests__/undo-redo.spec.tsx | 118 +++++++
.../__tests__/version-history-button.spec.tsx | 68 ++++
.../header/__tests__/view-history.spec.tsx | 276 +++++++++++++++
.../header/scroll-to-selected-node-button.tsx | 11 +-
.../components/workflow/header/undo-redo.tsx | 26 +-
.../workflow/header/view-history.tsx | 27 +-
.../_base/components/node-control.spec.tsx | 164 +++++----
.../nodes/_base/components/node-control.tsx | 11 +-
.../trigger-webhook/__tests__/panel.spec.tsx | 167 +++++----
.../operator/__tests__/add-block.spec.tsx | 225 ++++++++++++
.../operator/__tests__/control.spec.tsx | 136 ++++++++
.../panel/__tests__/inputs-panel.spec.tsx | 323 ++++++++++++++++++
.../workflow/panel/__tests__/record.spec.tsx | 163 +++++++++
.../workflow/run/__tests__/meta.spec.tsx | 68 ++++
.../run/__tests__/output-panel.spec.tsx | 137 ++++++++
.../run/__tests__/result-text.spec.tsx | 88 +++++
.../workflow/run/__tests__/status.spec.tsx | 131 +++++++
.../__tests__/error-handle-on-node.spec.tsx | 84 +++++
.../components/__tests__/node-handle.spec.tsx | 130 +++++++
web/eslint-suppressions.json | 5 -
39 files changed, 3538 insertions(+), 203 deletions(-)
create mode 100644 web/app/components/workflow/__tests__/candidate-node.spec.tsx
create mode 100644 web/app/components/workflow/__tests__/custom-connection-line.spec.tsx
create mode 100644 web/app/components/workflow/__tests__/custom-edge-linear-gradient-render.spec.tsx
create mode 100644 web/app/components/workflow/__tests__/dsl-export-confirm-modal.spec.tsx
create mode 100644 web/app/components/workflow/__tests__/features.spec.tsx
create mode 100644 web/app/components/workflow/__tests__/syncing-data-modal.spec.tsx
create mode 100644 web/app/components/workflow/block-selector/__tests__/use-check-vertical-scrollbar.spec.ts
create mode 100644 web/app/components/workflow/block-selector/__tests__/use-sticky-scroll.spec.ts
create mode 100644 web/app/components/workflow/block-selector/__tests__/utils.spec.ts
create mode 100644 web/app/components/workflow/block-selector/__tests__/view-type-select.spec.tsx
create mode 100644 web/app/components/workflow/header/__tests__/chat-variable-button.spec.tsx
create mode 100644 web/app/components/workflow/header/__tests__/editing-title.spec.tsx
create mode 100644 web/app/components/workflow/header/__tests__/env-button.spec.tsx
create mode 100644 web/app/components/workflow/header/__tests__/global-variable-button.spec.tsx
create mode 100644 web/app/components/workflow/header/__tests__/restoring-title.spec.tsx
create mode 100644 web/app/components/workflow/header/__tests__/running-title.spec.tsx
create mode 100644 web/app/components/workflow/header/__tests__/scroll-to-selected-node-button.spec.tsx
create mode 100644 web/app/components/workflow/header/__tests__/undo-redo.spec.tsx
create mode 100644 web/app/components/workflow/header/__tests__/version-history-button.spec.tsx
create mode 100644 web/app/components/workflow/header/__tests__/view-history.spec.tsx
create mode 100644 web/app/components/workflow/operator/__tests__/add-block.spec.tsx
create mode 100644 web/app/components/workflow/operator/__tests__/control.spec.tsx
create mode 100644 web/app/components/workflow/panel/__tests__/inputs-panel.spec.tsx
create mode 100644 web/app/components/workflow/panel/__tests__/record.spec.tsx
create mode 100644 web/app/components/workflow/run/__tests__/meta.spec.tsx
create mode 100644 web/app/components/workflow/run/__tests__/output-panel.spec.tsx
create mode 100644 web/app/components/workflow/run/__tests__/result-text.spec.tsx
create mode 100644 web/app/components/workflow/run/__tests__/status.spec.tsx
create mode 100644 web/app/components/workflow/workflow-preview/components/__tests__/error-handle-on-node.spec.tsx
create mode 100644 web/app/components/workflow/workflow-preview/components/__tests__/node-handle.spec.tsx
diff --git a/web/app/components/workflow/__tests__/candidate-node.spec.tsx b/web/app/components/workflow/__tests__/candidate-node.spec.tsx
new file mode 100644
index 0000000000..3844bef7ab
--- /dev/null
+++ b/web/app/components/workflow/__tests__/candidate-node.spec.tsx
@@ -0,0 +1,40 @@
+import type { Node } from '../types'
+import { screen } from '@testing-library/react'
+import CandidateNode from '../candidate-node'
+import { BlockEnum } from '../types'
+import { renderWorkflowComponent } from './workflow-test-env'
+
+vi.mock('../candidate-node-main', () => ({
+ default: ({ candidateNode }: { candidateNode: Node }) => (
+ {candidateNode.id}
+ ),
+}))
+
+const createCandidateNode = (): Node => ({
+ id: 'candidate-node-1',
+ type: 'custom',
+ position: { x: 0, y: 0 },
+ data: {
+ type: BlockEnum.Start,
+ title: 'Candidate node',
+ desc: 'candidate',
+ },
+})
+
+describe('CandidateNode', () => {
+ it('should not render when candidateNode is missing from the workflow store', () => {
+ renderWorkflowComponent()
+
+ expect(screen.queryByTestId('candidate-node-main')).not.toBeInTheDocument()
+ })
+
+ it('should render CandidateNodeMain with the stored candidate node', () => {
+ renderWorkflowComponent(, {
+ initialStoreState: {
+ candidateNode: createCandidateNode(),
+ },
+ })
+
+ expect(screen.getByTestId('candidate-node-main')).toHaveTextContent('candidate-node-1')
+ })
+})
diff --git a/web/app/components/workflow/__tests__/custom-connection-line.spec.tsx b/web/app/components/workflow/__tests__/custom-connection-line.spec.tsx
new file mode 100644
index 0000000000..aaaf18153d
--- /dev/null
+++ b/web/app/components/workflow/__tests__/custom-connection-line.spec.tsx
@@ -0,0 +1,81 @@
+import type { ComponentProps } from 'react'
+import { render } from '@testing-library/react'
+import { getBezierPath, Position } from 'reactflow'
+import CustomConnectionLine from '../custom-connection-line'
+
+const createConnectionLineProps = (
+ overrides: Partial> = {},
+): ComponentProps => ({
+ fromX: 10,
+ fromY: 20,
+ toX: 70,
+ toY: 80,
+ fromPosition: Position.Right,
+ toPosition: Position.Left,
+ connectionLineType: undefined,
+ connectionStatus: null,
+ ...overrides,
+} as ComponentProps)
+
+describe('CustomConnectionLine', () => {
+ it('should render the bezier path and target marker', () => {
+ const [expectedPath] = getBezierPath({
+ sourceX: 10,
+ sourceY: 20,
+ sourcePosition: Position.Right,
+ targetX: 70,
+ targetY: 80,
+ targetPosition: Position.Left,
+ curvature: 0.16,
+ })
+
+ const { container } = render(
+ ,
+ )
+
+ const path = container.querySelector('path')
+ const marker = container.querySelector('rect')
+
+ expect(path).toHaveAttribute('fill', 'none')
+ expect(path).toHaveAttribute('stroke', '#D0D5DD')
+ expect(path).toHaveAttribute('stroke-width', '2')
+ expect(path).toHaveAttribute('d', expectedPath)
+
+ expect(marker).toHaveAttribute('x', '70')
+ expect(marker).toHaveAttribute('y', '76')
+ expect(marker).toHaveAttribute('width', '2')
+ expect(marker).toHaveAttribute('height', '8')
+ expect(marker).toHaveAttribute('fill', '#2970FF')
+ })
+
+ it('should update the path when the endpoints change', () => {
+ const [expectedPath] = getBezierPath({
+ sourceX: 30,
+ sourceY: 40,
+ sourcePosition: Position.Right,
+ targetX: 160,
+ targetY: 200,
+ targetPosition: Position.Left,
+ curvature: 0.16,
+ })
+
+ const { container } = render(
+ ,
+ )
+
+ expect(container.querySelector('path')).toHaveAttribute('d', expectedPath)
+ expect(container.querySelector('rect')).toHaveAttribute('x', '160')
+ expect(container.querySelector('rect')).toHaveAttribute('y', '196')
+ })
+})
diff --git a/web/app/components/workflow/__tests__/custom-edge-linear-gradient-render.spec.tsx b/web/app/components/workflow/__tests__/custom-edge-linear-gradient-render.spec.tsx
new file mode 100644
index 0000000000..e962923158
--- /dev/null
+++ b/web/app/components/workflow/__tests__/custom-edge-linear-gradient-render.spec.tsx
@@ -0,0 +1,57 @@
+import { render } from '@testing-library/react'
+import CustomEdgeLinearGradientRender from '../custom-edge-linear-gradient-render'
+
+describe('CustomEdgeLinearGradientRender', () => {
+ it('should render gradient definition with the provided id and positions', () => {
+ const { container } = render(
+ ,
+ )
+
+ const gradient = container.querySelector('linearGradient')
+ expect(gradient).toHaveAttribute('id', 'edge-gradient')
+ expect(gradient).toHaveAttribute('gradientUnits', 'userSpaceOnUse')
+ expect(gradient).toHaveAttribute('x1', '10')
+ expect(gradient).toHaveAttribute('y1', '20')
+ expect(gradient).toHaveAttribute('x2', '30')
+ expect(gradient).toHaveAttribute('y2', '40')
+ })
+
+ it('should render start and stop colors at both ends of the gradient', () => {
+ const { container } = render(
+ ,
+ )
+
+ const stops = container.querySelectorAll('stop')
+ expect(stops).toHaveLength(2)
+ expect(stops[0]).toHaveAttribute('offset', '0%')
+ expect(stops[0].getAttribute('style')).toContain('stop-color: rgb(17, 17, 17)')
+ expect(stops[0].getAttribute('style')).toContain('stop-opacity: 1')
+ expect(stops[1]).toHaveAttribute('offset', '100%')
+ expect(stops[1].getAttribute('style')).toContain('stop-color: rgb(34, 34, 34)')
+ expect(stops[1].getAttribute('style')).toContain('stop-opacity: 1')
+ })
+})
diff --git a/web/app/components/workflow/__tests__/dsl-export-confirm-modal.spec.tsx b/web/app/components/workflow/__tests__/dsl-export-confirm-modal.spec.tsx
new file mode 100644
index 0000000000..1e0ba380cd
--- /dev/null
+++ b/web/app/components/workflow/__tests__/dsl-export-confirm-modal.spec.tsx
@@ -0,0 +1,127 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import DSLExportConfirmModal from '../dsl-export-confirm-modal'
+
+const envList = [
+ {
+ id: 'env-1',
+ name: 'SECRET_TOKEN',
+ value: 'masked-value',
+ value_type: 'secret' as const,
+ description: 'secret token',
+ },
+]
+
+const multiEnvList = [
+ ...envList,
+ {
+ id: 'env-2',
+ name: 'SERVICE_KEY',
+ value: 'another-secret',
+ value_type: 'secret' as const,
+ description: 'service key',
+ },
+]
+
+describe('DSLExportConfirmModal', () => {
+ it('should render environment rows and close when cancel is clicked', async () => {
+ const user = userEvent.setup()
+ const onConfirm = vi.fn()
+ const onClose = vi.fn()
+
+ render(
+ ,
+ )
+
+ expect(screen.getByText('SECRET_TOKEN')).toBeInTheDocument()
+ expect(screen.getByText('masked-value')).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
+
+ expect(onClose).toHaveBeenCalledTimes(1)
+ expect(onConfirm).not.toHaveBeenCalled()
+ })
+
+ it('should confirm with exportSecrets=false by default', async () => {
+ const user = userEvent.setup()
+ const onConfirm = vi.fn()
+ const onClose = vi.fn()
+
+ render(
+ ,
+ )
+
+ await user.click(screen.getByRole('button', { name: 'workflow.env.export.ignore' }))
+
+ expect(onConfirm).toHaveBeenCalledWith(false)
+ expect(onClose).toHaveBeenCalledTimes(1)
+ })
+
+ it('should confirm with exportSecrets=true after toggling the checkbox', async () => {
+ const user = userEvent.setup()
+ const onConfirm = vi.fn()
+ const onClose = vi.fn()
+
+ render(
+ ,
+ )
+
+ await user.click(screen.getByRole('checkbox'))
+ await user.click(screen.getByRole('button', { name: 'workflow.env.export.export' }))
+
+ expect(onConfirm).toHaveBeenCalledWith(true)
+ expect(onClose).toHaveBeenCalledTimes(1)
+ })
+
+ it('should also toggle exportSecrets when the label text is clicked', async () => {
+ const user = userEvent.setup()
+ const onConfirm = vi.fn()
+ const onClose = vi.fn()
+
+ render(
+ ,
+ )
+
+ await user.click(screen.getByText('workflow.env.export.checkbox'))
+ await user.click(screen.getByRole('button', { name: 'workflow.env.export.export' }))
+
+ expect(onConfirm).toHaveBeenCalledWith(true)
+ expect(onClose).toHaveBeenCalledTimes(1)
+ })
+
+ it('should render border separators for all rows except the last one', () => {
+ render(
+ ,
+ )
+
+ const firstNameCell = screen.getByText('SECRET_TOKEN').closest('td')
+ const lastNameCell = screen.getByText('SERVICE_KEY').closest('td')
+ const firstValueCell = screen.getByText('masked-value').closest('td')
+ const lastValueCell = screen.getByText('another-secret').closest('td')
+
+ expect(firstNameCell).toHaveClass('border-b')
+ expect(firstValueCell).toHaveClass('border-b')
+ expect(lastNameCell).not.toHaveClass('border-b')
+ expect(lastValueCell).not.toHaveClass('border-b')
+ })
+})
diff --git a/web/app/components/workflow/__tests__/features.spec.tsx b/web/app/components/workflow/__tests__/features.spec.tsx
new file mode 100644
index 0000000000..d7e2cb13ae
--- /dev/null
+++ b/web/app/components/workflow/__tests__/features.spec.tsx
@@ -0,0 +1,193 @@
+import type { InputVar } from '../types'
+import type { PromptVariable } from '@/models/debug'
+import { screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import ReactFlow, { ReactFlowProvider, useNodes } from 'reactflow'
+import Features from '../features'
+import { InputVarType } from '../types'
+import { createStartNode } from './fixtures'
+import { renderWorkflowComponent } from './workflow-test-env'
+
+const mockHandleSyncWorkflowDraft = vi.fn()
+const mockHandleAddVariable = vi.fn()
+
+let mockIsChatMode = true
+let mockNodesReadOnly = false
+
+vi.mock('../hooks', async () => {
+ const actual = await vi.importActual('../hooks')
+ return {
+ ...actual,
+ useIsChatMode: () => mockIsChatMode,
+ useNodesReadOnly: () => ({
+ nodesReadOnly: mockNodesReadOnly,
+ }),
+ useNodesSyncDraft: () => ({
+ handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
+ }),
+ }
+})
+
+vi.mock('../nodes/start/use-config', () => ({
+ default: () => ({
+ handleAddVariable: mockHandleAddVariable,
+ }),
+}))
+
+vi.mock('@/app/components/base/features/new-feature-panel', () => ({
+ default: ({
+ show,
+ isChatMode,
+ disabled,
+ onChange,
+ onClose,
+ onAutoAddPromptVariable,
+ workflowVariables,
+ }: {
+ show: boolean
+ isChatMode: boolean
+ disabled: boolean
+ onChange: () => void
+ onClose: () => void
+ onAutoAddPromptVariable: (variables: PromptVariable[]) => void
+ workflowVariables: InputVar[]
+ }) => {
+ if (!show)
+ return null
+
+ return (
+
+ {isChatMode ? 'chat mode' : 'completion mode'}
+ {disabled ? 'panel disabled' : 'panel enabled'}
+
+ {workflowVariables.map(variable => (
+ -
+ {`${variable.label}:${variable.variable}`}
+
+ ))}
+
+
+
+
+
+
+ )
+ },
+}))
+
+const startNode = createStartNode({
+ id: 'start-node',
+ data: {
+ variables: [{ variable: 'existing_variable', label: 'Existing Variable' }],
+ },
+})
+
+const DelayedFeatures = () => {
+ const nodes = useNodes()
+
+ if (!nodes.length)
+ return null
+
+ return
+}
+
+const renderFeatures = (options?: Parameters[1]) => {
+ return renderWorkflowComponent(
+
+
+
+
+
+
,
+ options,
+ )
+}
+
+describe('Features', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockIsChatMode = true
+ mockNodesReadOnly = false
+ })
+
+ describe('Rendering', () => {
+ it('should pass workflow context to the feature panel', () => {
+ renderFeatures()
+
+ expect(screen.getByText('chat mode')).toBeInTheDocument()
+ expect(screen.getByText('panel enabled')).toBeInTheDocument()
+ expect(screen.getByRole('list', { name: 'workflow variables' })).toHaveTextContent('Existing Variable:existing_variable')
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should sync the draft and open the workflow feature panel when users change features', async () => {
+ const user = userEvent.setup()
+ const { store } = renderFeatures()
+
+ await user.click(screen.getByRole('button', { name: 'open features' }))
+
+ expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledTimes(1)
+ expect(store.getState().showFeaturesPanel).toBe(true)
+ })
+
+ it('should close the workflow feature panel and transform required prompt variables', async () => {
+ const user = userEvent.setup()
+ const { store } = renderFeatures({
+ initialStoreState: {
+ showFeaturesPanel: true,
+ },
+ })
+
+ await user.click(screen.getByRole('button', { name: 'close features' }))
+ expect(store.getState().showFeaturesPanel).toBe(false)
+
+ await user.click(screen.getByRole('button', { name: 'add required variable' }))
+ expect(mockHandleAddVariable).toHaveBeenCalledWith({
+ variable: 'opening_statement',
+ label: 'Opening Statement',
+ type: InputVarType.textInput,
+ max_length: 200,
+ required: true,
+ options: [],
+ })
+ })
+
+ it('should default prompt variables to optional when required is omitted', async () => {
+ const user = userEvent.setup()
+
+ renderFeatures()
+
+ await user.click(screen.getByRole('button', { name: 'add optional variable' }))
+ expect(mockHandleAddVariable).toHaveBeenCalledWith({
+ variable: 'optional_statement',
+ label: 'Optional Statement',
+ type: InputVarType.textInput,
+ max_length: 120,
+ required: false,
+ options: [],
+ })
+ })
+ })
+})
diff --git a/web/app/components/workflow/__tests__/reactflow-mock-state.ts b/web/app/components/workflow/__tests__/reactflow-mock-state.ts
index dd7a73d2a9..a90bdbaed1 100644
--- a/web/app/components/workflow/__tests__/reactflow-mock-state.ts
+++ b/web/app/components/workflow/__tests__/reactflow-mock-state.ts
@@ -16,8 +16,8 @@ import * as React from 'react'
type MockNode = {
id: string
position: { x: number, y: number }
- width?: number
- height?: number
+ width?: number | null
+ height?: number | null
parentId?: string
data: Record
}
diff --git a/web/app/components/workflow/__tests__/syncing-data-modal.spec.tsx b/web/app/components/workflow/__tests__/syncing-data-modal.spec.tsx
new file mode 100644
index 0000000000..6805037d51
--- /dev/null
+++ b/web/app/components/workflow/__tests__/syncing-data-modal.spec.tsx
@@ -0,0 +1,22 @@
+import SyncingDataModal from '../syncing-data-modal'
+import { renderWorkflowComponent } from './workflow-test-env'
+
+describe('SyncingDataModal', () => {
+ it('should not render when workflow draft syncing is disabled', () => {
+ const { container } = renderWorkflowComponent()
+
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ it('should render the fullscreen overlay when workflow draft syncing is enabled', () => {
+ const { container } = renderWorkflowComponent(, {
+ initialStoreState: {
+ isSyncingWorkflowDraft: true,
+ },
+ })
+
+ const overlay = container.firstElementChild
+ expect(overlay).toHaveClass('absolute', 'inset-0')
+ expect(overlay).toHaveClass('z-[9999]')
+ })
+})
diff --git a/web/app/components/workflow/block-selector/__tests__/use-check-vertical-scrollbar.spec.ts b/web/app/components/workflow/block-selector/__tests__/use-check-vertical-scrollbar.spec.ts
new file mode 100644
index 0000000000..a31d6035db
--- /dev/null
+++ b/web/app/components/workflow/block-selector/__tests__/use-check-vertical-scrollbar.spec.ts
@@ -0,0 +1,108 @@
+import type * as React from 'react'
+import { act, renderHook } from '@testing-library/react'
+import useCheckVerticalScrollbar from '../use-check-vertical-scrollbar'
+
+const resizeObserve = vi.fn()
+const resizeDisconnect = vi.fn()
+const mutationObserve = vi.fn()
+const mutationDisconnect = vi.fn()
+
+let resizeCallback: ResizeObserverCallback | null = null
+let mutationCallback: MutationCallback | null = null
+
+class MockResizeObserver implements ResizeObserver {
+ observe = resizeObserve
+ unobserve = vi.fn()
+ disconnect = resizeDisconnect
+
+ constructor(callback: ResizeObserverCallback) {
+ resizeCallback = callback
+ }
+}
+
+class MockMutationObserver implements MutationObserver {
+ observe = mutationObserve
+ disconnect = mutationDisconnect
+ takeRecords = vi.fn(() => [])
+
+ constructor(callback: MutationCallback) {
+ mutationCallback = callback
+ }
+}
+
+const setElementHeights = (element: HTMLElement, scrollHeight: number, clientHeight: number) => {
+ Object.defineProperty(element, 'scrollHeight', {
+ configurable: true,
+ value: scrollHeight,
+ })
+ Object.defineProperty(element, 'clientHeight', {
+ configurable: true,
+ value: clientHeight,
+ })
+}
+
+describe('useCheckVerticalScrollbar', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ resizeCallback = null
+ mutationCallback = null
+ vi.stubGlobal('ResizeObserver', MockResizeObserver)
+ vi.stubGlobal('MutationObserver', MockMutationObserver)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('should return false when the element ref is empty', () => {
+ const ref = { current: null } as React.RefObject
+
+ const { result } = renderHook(() => useCheckVerticalScrollbar(ref))
+
+ expect(result.current).toBe(false)
+ expect(resizeObserve).not.toHaveBeenCalled()
+ expect(mutationObserve).not.toHaveBeenCalled()
+ })
+
+ it('should detect the initial scrollbar state and react to observer updates', () => {
+ const element = document.createElement('div')
+ setElementHeights(element, 200, 100)
+ const ref = { current: element } as React.RefObject
+
+ const { result } = renderHook(() => useCheckVerticalScrollbar(ref))
+
+ expect(result.current).toBe(true)
+ expect(resizeObserve).toHaveBeenCalledWith(element)
+ expect(mutationObserve).toHaveBeenCalledWith(element, {
+ childList: true,
+ subtree: true,
+ characterData: true,
+ })
+
+ setElementHeights(element, 100, 100)
+ act(() => {
+ resizeCallback?.([] as ResizeObserverEntry[], new MockResizeObserver(() => {}))
+ })
+
+ expect(result.current).toBe(false)
+
+ setElementHeights(element, 180, 100)
+ act(() => {
+ mutationCallback?.([] as MutationRecord[], new MockMutationObserver(() => {}))
+ })
+
+ expect(result.current).toBe(true)
+ })
+
+ it('should disconnect observers on unmount', () => {
+ const element = document.createElement('div')
+ setElementHeights(element, 120, 100)
+ const ref = { current: element } as React.RefObject
+
+ const { unmount } = renderHook(() => useCheckVerticalScrollbar(ref))
+ unmount()
+
+ expect(resizeDisconnect).toHaveBeenCalledTimes(1)
+ expect(mutationDisconnect).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/web/app/components/workflow/block-selector/__tests__/use-sticky-scroll.spec.ts b/web/app/components/workflow/block-selector/__tests__/use-sticky-scroll.spec.ts
new file mode 100644
index 0000000000..5949a74682
--- /dev/null
+++ b/web/app/components/workflow/block-selector/__tests__/use-sticky-scroll.spec.ts
@@ -0,0 +1,103 @@
+import type * as React from 'react'
+import { act, renderHook } from '@testing-library/react'
+import useStickyScroll, { ScrollPosition } from '../use-sticky-scroll'
+
+const setRect = (element: HTMLElement, top: number, height: number) => {
+ element.getBoundingClientRect = vi.fn(() => new DOMRect(0, top, 100, height))
+}
+
+describe('useStickyScroll', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ const runScroll = (handleScroll: () => void) => {
+ act(() => {
+ handleScroll()
+ vi.advanceTimersByTime(120)
+ })
+ }
+
+ it('should keep the default state when refs are missing', () => {
+ const wrapElemRef = { current: null } as React.RefObject
+ const nextToStickyELemRef = { current: null } as React.RefObject
+
+ const { result } = renderHook(() =>
+ useStickyScroll({
+ wrapElemRef,
+ nextToStickyELemRef,
+ }),
+ )
+
+ runScroll(result.current.handleScroll)
+
+ expect(result.current.scrollPosition).toBe(ScrollPosition.belowTheWrap)
+ })
+
+ it('should mark the sticky element as below the wrapper when it is outside the visible area', () => {
+ const wrapElement = document.createElement('div')
+ const nextElement = document.createElement('div')
+ setRect(wrapElement, 100, 200)
+ setRect(nextElement, 320, 20)
+
+ const wrapElemRef = { current: wrapElement } as React.RefObject
+ const nextToStickyELemRef = { current: nextElement } as React.RefObject
+
+ const { result } = renderHook(() =>
+ useStickyScroll({
+ wrapElemRef,
+ nextToStickyELemRef,
+ }),
+ )
+
+ runScroll(result.current.handleScroll)
+
+ expect(result.current.scrollPosition).toBe(ScrollPosition.belowTheWrap)
+ })
+
+ it('should mark the sticky element as showing when it is within the wrapper', () => {
+ const wrapElement = document.createElement('div')
+ const nextElement = document.createElement('div')
+ setRect(wrapElement, 100, 200)
+ setRect(nextElement, 220, 20)
+
+ const wrapElemRef = { current: wrapElement } as React.RefObject
+ const nextToStickyELemRef = { current: nextElement } as React.RefObject
+
+ const { result } = renderHook(() =>
+ useStickyScroll({
+ wrapElemRef,
+ nextToStickyELemRef,
+ }),
+ )
+
+ runScroll(result.current.handleScroll)
+
+ expect(result.current.scrollPosition).toBe(ScrollPosition.showing)
+ })
+
+ it('should mark the sticky element as above the wrapper when it has scrolled past the top', () => {
+ const wrapElement = document.createElement('div')
+ const nextElement = document.createElement('div')
+ setRect(wrapElement, 100, 200)
+ setRect(nextElement, 90, 20)
+
+ const wrapElemRef = { current: wrapElement } as React.RefObject
+ const nextToStickyELemRef = { current: nextElement } as React.RefObject
+
+ const { result } = renderHook(() =>
+ useStickyScroll({
+ wrapElemRef,
+ nextToStickyELemRef,
+ }),
+ )
+
+ runScroll(result.current.handleScroll)
+
+ expect(result.current.scrollPosition).toBe(ScrollPosition.aboveTheWrap)
+ })
+})
diff --git a/web/app/components/workflow/block-selector/__tests__/utils.spec.ts b/web/app/components/workflow/block-selector/__tests__/utils.spec.ts
new file mode 100644
index 0000000000..b003ef7561
--- /dev/null
+++ b/web/app/components/workflow/block-selector/__tests__/utils.spec.ts
@@ -0,0 +1,108 @@
+import type { DataSourceItem } from '../types'
+import { transformDataSourceToTool } from '../utils'
+
+const createLocalizedText = (text: string) => ({
+ en_US: text,
+ zh_Hans: text,
+})
+
+const createDataSourceItem = (overrides: Partial = {}): DataSourceItem => ({
+ plugin_id: 'plugin-1',
+ plugin_unique_identifier: 'plugin-1@provider',
+ provider: 'provider-a',
+ declaration: {
+ credentials_schema: [{ name: 'api_key' }],
+ provider_type: 'hosted',
+ identity: {
+ author: 'Dify',
+ description: createLocalizedText('Datasource provider'),
+ icon: 'provider-icon',
+ label: createLocalizedText('Provider A'),
+ name: 'provider-a',
+ tags: ['retrieval', 'storage'],
+ },
+ datasources: [
+ {
+ description: createLocalizedText('Search in documents'),
+ identity: {
+ author: 'Dify',
+ label: createLocalizedText('Document Search'),
+ name: 'document_search',
+ provider: 'provider-a',
+ },
+ parameters: [{ name: 'query', type: 'string' }],
+ output_schema: {
+ type: 'object',
+ properties: {
+ result: { type: 'string' },
+ },
+ },
+ },
+ ],
+ },
+ is_authorized: true,
+ ...overrides,
+})
+
+describe('transformDataSourceToTool', () => {
+ it('should map datasource provider fields to tool shape', () => {
+ const dataSourceItem = createDataSourceItem()
+
+ const result = transformDataSourceToTool(dataSourceItem)
+
+ expect(result).toMatchObject({
+ id: 'plugin-1',
+ provider: 'provider-a',
+ name: 'provider-a',
+ author: 'Dify',
+ description: createLocalizedText('Datasource provider'),
+ icon: 'provider-icon',
+ label: createLocalizedText('Provider A'),
+ type: 'hosted',
+ allow_delete: true,
+ is_authorized: true,
+ is_team_authorization: true,
+ labels: ['retrieval', 'storage'],
+ plugin_id: 'plugin-1',
+ plugin_unique_identifier: 'plugin-1@provider',
+ credentialsSchema: [{ name: 'api_key' }],
+ meta: { version: '' },
+ })
+ expect(result.team_credentials).toEqual({})
+ expect(result.tools).toEqual([
+ {
+ name: 'document_search',
+ author: 'Dify',
+ label: createLocalizedText('Document Search'),
+ description: createLocalizedText('Search in documents'),
+ parameters: [{ name: 'query', type: 'string' }],
+ labels: [],
+ output_schema: {
+ type: 'object',
+ properties: {
+ result: { type: 'string' },
+ },
+ },
+ },
+ ])
+ })
+
+ it('should fallback to empty arrays when tags and credentials schema are missing', () => {
+ const baseDataSourceItem = createDataSourceItem()
+ const dataSourceItem = createDataSourceItem({
+ declaration: {
+ ...baseDataSourceItem.declaration,
+ credentials_schema: undefined as unknown as DataSourceItem['declaration']['credentials_schema'],
+ identity: {
+ ...baseDataSourceItem.declaration.identity,
+ tags: undefined as unknown as DataSourceItem['declaration']['identity']['tags'],
+ },
+ },
+ })
+
+ const result = transformDataSourceToTool(dataSourceItem)
+
+ expect(result.labels).toEqual([])
+ expect(result.credentialsSchema).toEqual([])
+ })
+})
diff --git a/web/app/components/workflow/block-selector/__tests__/view-type-select.spec.tsx b/web/app/components/workflow/block-selector/__tests__/view-type-select.spec.tsx
new file mode 100644
index 0000000000..40e5bacd83
--- /dev/null
+++ b/web/app/components/workflow/block-selector/__tests__/view-type-select.spec.tsx
@@ -0,0 +1,57 @@
+import { fireEvent, render } from '@testing-library/react'
+import ViewTypeSelect, { ViewType } from '../view-type-select'
+
+const getViewOptions = (container: HTMLElement) => {
+ const options = container.firstElementChild?.children
+ if (!options || options.length !== 2)
+ throw new Error('Expected two view options')
+ return [options[0] as HTMLDivElement, options[1] as HTMLDivElement]
+}
+
+describe('ViewTypeSelect', () => {
+ it('should highlight the active view type', () => {
+ const onChange = vi.fn()
+ const { container } = render(
+ ,
+ )
+
+ const [flatOption, treeOption] = getViewOptions(container)
+
+ expect(flatOption).toHaveClass('bg-components-segmented-control-item-active-bg')
+ expect(treeOption).toHaveClass('cursor-pointer')
+ })
+
+ it('should call onChange when switching to a different view type', () => {
+ const onChange = vi.fn()
+ const { container } = render(
+ ,
+ )
+
+ const [, treeOption] = getViewOptions(container)
+ fireEvent.click(treeOption)
+
+ expect(onChange).toHaveBeenCalledWith(ViewType.tree)
+ expect(onChange).toHaveBeenCalledTimes(1)
+ })
+
+ it('should ignore clicks on the current view type', () => {
+ const onChange = vi.fn()
+ const { container } = render(
+ ,
+ )
+
+ const [, treeOption] = getViewOptions(container)
+ fireEvent.click(treeOption)
+
+ expect(onChange).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/app/components/workflow/block-selector/use-check-vertical-scrollbar.ts b/web/app/components/workflow/block-selector/use-check-vertical-scrollbar.ts
index e8f5fc0559..e5c1f208fb 100644
--- a/web/app/components/workflow/block-selector/use-check-vertical-scrollbar.ts
+++ b/web/app/components/workflow/block-selector/use-check-vertical-scrollbar.ts
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
-const useCheckVerticalScrollbar = (ref: React.RefObject) => {
+const useCheckVerticalScrollbar = (ref: React.RefObject) => {
const [hasVerticalScrollbar, setHasVerticalScrollbar] = useState(false)
useEffect(() => {
diff --git a/web/app/components/workflow/header/__tests__/chat-variable-button.spec.tsx b/web/app/components/workflow/header/__tests__/chat-variable-button.spec.tsx
new file mode 100644
index 0000000000..ebe8321044
--- /dev/null
+++ b/web/app/components/workflow/header/__tests__/chat-variable-button.spec.tsx
@@ -0,0 +1,59 @@
+import { fireEvent, screen } from '@testing-library/react'
+import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
+import ChatVariableButton from '../chat-variable-button'
+
+let mockTheme: 'light' | 'dark' = 'light'
+
+vi.mock('@/hooks/use-theme', () => ({
+ default: () => ({
+ theme: mockTheme,
+ }),
+}))
+
+describe('ChatVariableButton', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockTheme = 'light'
+ })
+
+ it('opens the chat variable panel and closes the other workflow panels', () => {
+ const { store } = renderWorkflowComponent(, {
+ initialStoreState: {
+ showEnvPanel: true,
+ showGlobalVariablePanel: true,
+ showDebugAndPreviewPanel: true,
+ },
+ })
+
+ fireEvent.click(screen.getByRole('button'))
+
+ expect(store.getState().showChatVariablePanel).toBe(true)
+ expect(store.getState().showEnvPanel).toBe(false)
+ expect(store.getState().showGlobalVariablePanel).toBe(false)
+ expect(store.getState().showDebugAndPreviewPanel).toBe(false)
+ })
+
+ it('applies the active dark theme styles when the chat variable panel is visible', () => {
+ mockTheme = 'dark'
+ renderWorkflowComponent(, {
+ initialStoreState: {
+ showChatVariablePanel: true,
+ },
+ })
+
+ expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-sm')
+ })
+
+ it('stays disabled without mutating panel state', () => {
+ const { store } = renderWorkflowComponent(, {
+ initialStoreState: {
+ showChatVariablePanel: false,
+ },
+ })
+
+ fireEvent.click(screen.getByRole('button'))
+
+ expect(screen.getByRole('button')).toBeDisabled()
+ expect(store.getState().showChatVariablePanel).toBe(false)
+ })
+})
diff --git a/web/app/components/workflow/header/__tests__/editing-title.spec.tsx b/web/app/components/workflow/header/__tests__/editing-title.spec.tsx
new file mode 100644
index 0000000000..2dbb1b4b86
--- /dev/null
+++ b/web/app/components/workflow/header/__tests__/editing-title.spec.tsx
@@ -0,0 +1,63 @@
+import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
+import EditingTitle from '../editing-title'
+
+const mockFormatTime = vi.fn()
+const mockFormatTimeFromNow = vi.fn()
+
+vi.mock('@/hooks/use-timestamp', () => ({
+ default: () => ({
+ formatTime: mockFormatTime,
+ }),
+}))
+
+vi.mock('@/hooks/use-format-time-from-now', () => ({
+ useFormatTimeFromNow: () => ({
+ formatTimeFromNow: mockFormatTimeFromNow,
+ }),
+}))
+
+describe('EditingTitle', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockFormatTime.mockReturnValue('08:00:00')
+ mockFormatTimeFromNow.mockReturnValue('2 hours ago')
+ })
+
+ it('should render autosave, published time, and syncing status when the draft has metadata', () => {
+ const { container } = renderWorkflowComponent(, {
+ initialStoreState: {
+ draftUpdatedAt: 1_710_000_000_000,
+ publishedAt: 1_710_003_600_000,
+ isSyncingWorkflowDraft: true,
+ maximizeCanvas: true,
+ },
+ })
+
+ expect(mockFormatTime).toHaveBeenCalledWith(1_710_000_000, 'HH:mm:ss')
+ expect(mockFormatTimeFromNow).toHaveBeenCalledWith(1_710_003_600_000)
+ expect(container.firstChild).toHaveClass('ml-2')
+ expect(container).toHaveTextContent('workflow.common.autoSaved')
+ expect(container).toHaveTextContent('08:00:00')
+ expect(container).toHaveTextContent('workflow.common.published')
+ expect(container).toHaveTextContent('2 hours ago')
+ expect(container).toHaveTextContent('workflow.common.syncingData')
+ })
+
+ it('should render unpublished status without autosave metadata when the workflow has not been published', () => {
+ const { container } = renderWorkflowComponent(, {
+ initialStoreState: {
+ draftUpdatedAt: 0,
+ publishedAt: 0,
+ isSyncingWorkflowDraft: false,
+ maximizeCanvas: false,
+ },
+ })
+
+ expect(mockFormatTime).not.toHaveBeenCalled()
+ expect(mockFormatTimeFromNow).not.toHaveBeenCalled()
+ expect(container.firstChild).not.toHaveClass('ml-2')
+ expect(container).toHaveTextContent('workflow.common.unpublished')
+ expect(container).not.toHaveTextContent('workflow.common.autoSaved')
+ expect(container).not.toHaveTextContent('workflow.common.syncingData')
+ })
+})
diff --git a/web/app/components/workflow/header/__tests__/env-button.spec.tsx b/web/app/components/workflow/header/__tests__/env-button.spec.tsx
new file mode 100644
index 0000000000..268c54714e
--- /dev/null
+++ b/web/app/components/workflow/header/__tests__/env-button.spec.tsx
@@ -0,0 +1,68 @@
+import { fireEvent, screen } from '@testing-library/react'
+import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
+import EnvButton from '../env-button'
+
+const mockCloseAllInputFieldPanels = vi.fn()
+let mockTheme: 'light' | 'dark' = 'light'
+
+vi.mock('@/hooks/use-theme', () => ({
+ default: () => ({
+ theme: mockTheme,
+ }),
+}))
+
+vi.mock('@/app/components/rag-pipeline/hooks', () => ({
+ useInputFieldPanel: () => ({
+ closeAllInputFieldPanels: mockCloseAllInputFieldPanels,
+ }),
+}))
+
+describe('EnvButton', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockTheme = 'light'
+ })
+
+ it('should open the environment panel and close the other panels when clicked', () => {
+ const { store } = renderWorkflowComponent(, {
+ initialStoreState: {
+ showChatVariablePanel: true,
+ showGlobalVariablePanel: true,
+ showDebugAndPreviewPanel: true,
+ },
+ })
+
+ fireEvent.click(screen.getByRole('button'))
+
+ expect(store.getState().showEnvPanel).toBe(true)
+ expect(store.getState().showChatVariablePanel).toBe(false)
+ expect(store.getState().showGlobalVariablePanel).toBe(false)
+ expect(store.getState().showDebugAndPreviewPanel).toBe(false)
+ expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
+ })
+
+ it('should apply the active dark theme styles when the environment panel is visible', () => {
+ mockTheme = 'dark'
+ renderWorkflowComponent(, {
+ initialStoreState: {
+ showEnvPanel: true,
+ },
+ })
+
+ expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-sm')
+ })
+
+ it('should keep the button disabled when the disabled prop is true', () => {
+ const { store } = renderWorkflowComponent(, {
+ initialStoreState: {
+ showEnvPanel: false,
+ },
+ })
+
+ fireEvent.click(screen.getByRole('button'))
+
+ expect(screen.getByRole('button')).toBeDisabled()
+ expect(store.getState().showEnvPanel).toBe(false)
+ expect(mockCloseAllInputFieldPanels).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/app/components/workflow/header/__tests__/global-variable-button.spec.tsx b/web/app/components/workflow/header/__tests__/global-variable-button.spec.tsx
new file mode 100644
index 0000000000..fe17f940b8
--- /dev/null
+++ b/web/app/components/workflow/header/__tests__/global-variable-button.spec.tsx
@@ -0,0 +1,68 @@
+import { fireEvent, screen } from '@testing-library/react'
+import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
+import GlobalVariableButton from '../global-variable-button'
+
+const mockCloseAllInputFieldPanels = vi.fn()
+let mockTheme: 'light' | 'dark' = 'light'
+
+vi.mock('@/hooks/use-theme', () => ({
+ default: () => ({
+ theme: mockTheme,
+ }),
+}))
+
+vi.mock('@/app/components/rag-pipeline/hooks', () => ({
+ useInputFieldPanel: () => ({
+ closeAllInputFieldPanels: mockCloseAllInputFieldPanels,
+ }),
+}))
+
+describe('GlobalVariableButton', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockTheme = 'light'
+ })
+
+ it('should open the global variable panel and close the other panels when clicked', () => {
+ const { store } = renderWorkflowComponent(, {
+ initialStoreState: {
+ showEnvPanel: true,
+ showChatVariablePanel: true,
+ showDebugAndPreviewPanel: true,
+ },
+ })
+
+ fireEvent.click(screen.getByRole('button'))
+
+ expect(store.getState().showGlobalVariablePanel).toBe(true)
+ expect(store.getState().showEnvPanel).toBe(false)
+ expect(store.getState().showChatVariablePanel).toBe(false)
+ expect(store.getState().showDebugAndPreviewPanel).toBe(false)
+ expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
+ })
+
+ it('should apply the active dark theme styles when the global variable panel is visible', () => {
+ mockTheme = 'dark'
+ renderWorkflowComponent(, {
+ initialStoreState: {
+ showGlobalVariablePanel: true,
+ },
+ })
+
+ expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-sm')
+ })
+
+ it('should keep the button disabled when the disabled prop is true', () => {
+ const { store } = renderWorkflowComponent(, {
+ initialStoreState: {
+ showGlobalVariablePanel: false,
+ },
+ })
+
+ fireEvent.click(screen.getByRole('button'))
+
+ expect(screen.getByRole('button')).toBeDisabled()
+ expect(store.getState().showGlobalVariablePanel).toBe(false)
+ expect(mockCloseAllInputFieldPanels).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/app/components/workflow/header/__tests__/restoring-title.spec.tsx b/web/app/components/workflow/header/__tests__/restoring-title.spec.tsx
new file mode 100644
index 0000000000..f5d138af42
--- /dev/null
+++ b/web/app/components/workflow/header/__tests__/restoring-title.spec.tsx
@@ -0,0 +1,109 @@
+import type { VersionHistory } from '@/types/workflow'
+import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
+import { WorkflowVersion } from '../../types'
+import RestoringTitle from '../restoring-title'
+
+const mockFormatTime = vi.fn()
+const mockFormatTimeFromNow = vi.fn()
+
+vi.mock('@/hooks/use-timestamp', () => ({
+ default: () => ({
+ formatTime: mockFormatTime,
+ }),
+}))
+
+vi.mock('@/hooks/use-format-time-from-now', () => ({
+ useFormatTimeFromNow: () => ({
+ formatTimeFromNow: mockFormatTimeFromNow,
+ }),
+}))
+
+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('RestoringTitle', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockFormatTime.mockReturnValue('09:30:00')
+ mockFormatTimeFromNow.mockReturnValue('3 hours ago')
+ })
+
+ it('should render draft metadata when the current version is a draft', () => {
+ const currentVersion = createVersion({
+ version: WorkflowVersion.Draft,
+ })
+
+ const { container } = renderWorkflowComponent(, {
+ initialStoreState: {
+ currentVersion,
+ },
+ })
+
+ expect(mockFormatTimeFromNow).toHaveBeenCalledWith(currentVersion.updated_at * 1000)
+ expect(mockFormatTime).toHaveBeenCalledWith(currentVersion.created_at, 'HH:mm:ss')
+ expect(container).toHaveTextContent('workflow.versionHistory.currentDraft')
+ expect(container).toHaveTextContent('workflow.common.viewOnly')
+ expect(container).toHaveTextContent('workflow.common.unpublished')
+ expect(container).toHaveTextContent('3 hours ago 09:30:00')
+ expect(container).toHaveTextContent('Alice')
+ })
+
+ it('should render published metadata and fallback version name when the marked name is empty', () => {
+ const currentVersion = createVersion({
+ marked_name: '',
+ })
+
+ const { container } = renderWorkflowComponent(, {
+ initialStoreState: {
+ currentVersion,
+ },
+ })
+
+ expect(mockFormatTimeFromNow).toHaveBeenCalledWith(currentVersion.created_at * 1000)
+ expect(container).toHaveTextContent('workflow.versionHistory.defaultName')
+ expect(container).toHaveTextContent('workflow.common.published')
+ expect(container).toHaveTextContent('Alice')
+ })
+
+ it('should render an empty creator name when the version creator name is missing', () => {
+ const currentVersion = createVersion({
+ created_by: {
+ id: 'user-1',
+ name: '',
+ email: 'alice@example.com',
+ },
+ })
+
+ const { container } = renderWorkflowComponent(, {
+ initialStoreState: {
+ currentVersion,
+ },
+ })
+
+ expect(container).toHaveTextContent('workflow.common.published')
+ expect(container).not.toHaveTextContent('Alice')
+ })
+})
diff --git a/web/app/components/workflow/header/__tests__/running-title.spec.tsx b/web/app/components/workflow/header/__tests__/running-title.spec.tsx
new file mode 100644
index 0000000000..7d904ed74a
--- /dev/null
+++ b/web/app/components/workflow/header/__tests__/running-title.spec.tsx
@@ -0,0 +1,61 @@
+import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
+import RunningTitle from '../running-title'
+
+let mockIsChatMode = false
+const mockFormatWorkflowRunIdentifier = vi.fn()
+
+vi.mock('../../hooks', () => ({
+ useIsChatMode: () => mockIsChatMode,
+}))
+
+vi.mock('../../utils', () => ({
+ formatWorkflowRunIdentifier: (finishedAt?: number) => mockFormatWorkflowRunIdentifier(finishedAt),
+}))
+
+describe('RunningTitle', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockIsChatMode = false
+ mockFormatWorkflowRunIdentifier.mockReturnValue(' (14:30:25)')
+ })
+
+ it('should render the test run title in workflow mode', () => {
+ const { container } = renderWorkflowComponent(, {
+ initialStoreState: {
+ historyWorkflowData: {
+ id: 'history-1',
+ status: 'succeeded',
+ finished_at: 1_700_000_000,
+ },
+ },
+ })
+
+ expect(mockFormatWorkflowRunIdentifier).toHaveBeenCalledWith(1_700_000_000)
+ expect(container).toHaveTextContent('Test Run (14:30:25)')
+ expect(container).toHaveTextContent('workflow.common.viewOnly')
+ })
+
+ it('should render the test chat title in chat mode', () => {
+ mockIsChatMode = true
+
+ const { container } = renderWorkflowComponent(, {
+ initialStoreState: {
+ historyWorkflowData: {
+ id: 'history-2',
+ status: 'running',
+ finished_at: undefined,
+ },
+ },
+ })
+
+ expect(mockFormatWorkflowRunIdentifier).toHaveBeenCalledWith(undefined)
+ expect(container).toHaveTextContent('Test Chat (14:30:25)')
+ })
+
+ it('should handle missing workflow history data', () => {
+ const { container } = renderWorkflowComponent()
+
+ expect(mockFormatWorkflowRunIdentifier).toHaveBeenCalledWith(undefined)
+ expect(container).toHaveTextContent('Test Run (14:30:25)')
+ })
+})
diff --git a/web/app/components/workflow/header/__tests__/scroll-to-selected-node-button.spec.tsx b/web/app/components/workflow/header/__tests__/scroll-to-selected-node-button.spec.tsx
new file mode 100644
index 0000000000..7fbc70db23
--- /dev/null
+++ b/web/app/components/workflow/header/__tests__/scroll-to-selected-node-button.spec.tsx
@@ -0,0 +1,53 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { createNode } from '../../__tests__/fixtures'
+import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
+import ScrollToSelectedNodeButton from '../scroll-to-selected-node-button'
+
+const mockScrollToWorkflowNode = vi.fn()
+
+vi.mock('reactflow', async () =>
+ (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock())
+
+vi.mock('../../utils/node-navigation', () => ({
+ scrollToWorkflowNode: (nodeId: string) => mockScrollToWorkflowNode(nodeId),
+}))
+
+describe('ScrollToSelectedNodeButton', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ resetReactFlowMockState()
+ })
+
+ it('should render nothing when there is no selected node', () => {
+ rfState.nodes = [
+ createNode({
+ id: 'node-1',
+ data: { selected: false },
+ }),
+ ]
+
+ const { container } = render()
+
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('should render the action and scroll to the selected node when clicked', () => {
+ rfState.nodes = [
+ createNode({
+ id: 'node-1',
+ data: { selected: false },
+ }),
+ createNode({
+ id: 'node-2',
+ data: { selected: true },
+ }),
+ ]
+
+ render()
+
+ fireEvent.click(screen.getByText('workflow.panel.scrollToSelectedNode'))
+
+ expect(mockScrollToWorkflowNode).toHaveBeenCalledWith('node-2')
+ expect(mockScrollToWorkflowNode).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/web/app/components/workflow/header/__tests__/undo-redo.spec.tsx b/web/app/components/workflow/header/__tests__/undo-redo.spec.tsx
new file mode 100644
index 0000000000..767de6a6a8
--- /dev/null
+++ b/web/app/components/workflow/header/__tests__/undo-redo.spec.tsx
@@ -0,0 +1,118 @@
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import UndoRedo from '../undo-redo'
+
+type TemporalSnapshot = {
+ pastStates: unknown[]
+ futureStates: unknown[]
+}
+
+const mockUnsubscribe = vi.fn()
+const mockTemporalSubscribe = vi.fn()
+const mockHandleUndo = vi.fn()
+const mockHandleRedo = vi.fn()
+
+let latestTemporalListener: ((state: TemporalSnapshot) => void) | undefined
+let mockNodesReadOnly = false
+
+vi.mock('@/app/components/workflow/header/view-workflow-history', () => ({
+ default: () => ,
+}))
+
+vi.mock('@/app/components/workflow/hooks', () => ({
+ useNodesReadOnly: () => ({
+ nodesReadOnly: mockNodesReadOnly,
+ }),
+}))
+
+vi.mock('@/app/components/workflow/workflow-history-store', () => ({
+ useWorkflowHistoryStore: () => ({
+ store: {
+ temporal: {
+ subscribe: mockTemporalSubscribe,
+ },
+ },
+ shortcutsEnabled: true,
+ setShortcutsEnabled: vi.fn(),
+ }),
+}))
+
+vi.mock('@/app/components/base/divider', () => ({
+ default: () => ,
+}))
+
+vi.mock('@/app/components/workflow/operator/tip-popup', () => ({
+ default: ({ children }: { children?: React.ReactNode }) => <>{children}>,
+}))
+
+describe('UndoRedo', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockNodesReadOnly = false
+ latestTemporalListener = undefined
+ mockTemporalSubscribe.mockImplementation((listener: (state: TemporalSnapshot) => void) => {
+ latestTemporalListener = listener
+ return mockUnsubscribe
+ })
+ })
+
+ it('enables undo and redo when history exists and triggers the callbacks', () => {
+ render()
+
+ act(() => {
+ latestTemporalListener?.({
+ pastStates: [{}],
+ futureStates: [{}],
+ })
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: 'workflow.common.undo' }))
+ fireEvent.click(screen.getByRole('button', { name: 'workflow.common.redo' }))
+
+ expect(mockHandleUndo).toHaveBeenCalledTimes(1)
+ expect(mockHandleRedo).toHaveBeenCalledTimes(1)
+ })
+
+ it('keeps the buttons disabled before history is available', () => {
+ render()
+ const undoButton = screen.getByRole('button', { name: 'workflow.common.undo' })
+ const redoButton = screen.getByRole('button', { name: 'workflow.common.redo' })
+
+ fireEvent.click(undoButton)
+ fireEvent.click(redoButton)
+
+ expect(undoButton).toBeDisabled()
+ expect(redoButton).toBeDisabled()
+ expect(mockHandleUndo).not.toHaveBeenCalled()
+ expect(mockHandleRedo).not.toHaveBeenCalled()
+ })
+
+ it('does not trigger callbacks when the canvas is read only', () => {
+ mockNodesReadOnly = true
+ render()
+ const undoButton = screen.getByRole('button', { name: 'workflow.common.undo' })
+ const redoButton = screen.getByRole('button', { name: 'workflow.common.redo' })
+
+ act(() => {
+ latestTemporalListener?.({
+ pastStates: [{}],
+ futureStates: [{}],
+ })
+ })
+
+ fireEvent.click(undoButton)
+ fireEvent.click(redoButton)
+
+ expect(undoButton).toBeDisabled()
+ expect(redoButton).toBeDisabled()
+ expect(mockHandleUndo).not.toHaveBeenCalled()
+ expect(mockHandleRedo).not.toHaveBeenCalled()
+ })
+
+ it('unsubscribes from the temporal store on unmount', () => {
+ const { unmount } = render()
+
+ unmount()
+
+ expect(mockUnsubscribe).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/web/app/components/workflow/header/__tests__/version-history-button.spec.tsx b/web/app/components/workflow/header/__tests__/version-history-button.spec.tsx
new file mode 100644
index 0000000000..bc066adba5
--- /dev/null
+++ b/web/app/components/workflow/header/__tests__/version-history-button.spec.tsx
@@ -0,0 +1,68 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import VersionHistoryButton from '../version-history-button'
+
+let mockTheme: 'light' | 'dark' = 'light'
+
+vi.mock('@/hooks/use-theme', () => ({
+ default: () => ({
+ theme: mockTheme,
+ }),
+}))
+
+vi.mock('../../utils', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ getKeyboardKeyCodeBySystem: () => 'ctrl',
+ }
+})
+
+describe('VersionHistoryButton', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockTheme = 'light'
+ })
+
+ it('should call onClick when the button is clicked', () => {
+ const onClick = vi.fn()
+ render()
+
+ fireEvent.click(screen.getByRole('button'))
+
+ expect(onClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('should trigger onClick when the version history shortcut is pressed', () => {
+ const onClick = vi.fn()
+ render()
+
+ const keyboardEvent = new KeyboardEvent('keydown', {
+ key: 'H',
+ ctrlKey: true,
+ shiftKey: true,
+ bubbles: true,
+ cancelable: true,
+ })
+ Object.defineProperty(keyboardEvent, 'keyCode', { value: 72 })
+ Object.defineProperty(keyboardEvent, 'which', { value: 72 })
+ window.dispatchEvent(keyboardEvent)
+
+ expect(keyboardEvent.defaultPrevented).toBe(true)
+ expect(onClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('should render the tooltip popup content on hover', async () => {
+ render()
+
+ fireEvent.mouseEnter(screen.getByRole('button'))
+
+ expect(await screen.findByText('workflow.common.versionHistory')).toBeInTheDocument()
+ })
+
+ it('should apply dark theme styles when the theme is dark', () => {
+ mockTheme = 'dark'
+ render()
+
+ expect(screen.getByRole('button')).toHaveClass('border-black/5', 'bg-white/10', 'backdrop-blur-sm')
+ })
+})
diff --git a/web/app/components/workflow/header/__tests__/view-history.spec.tsx b/web/app/components/workflow/header/__tests__/view-history.spec.tsx
new file mode 100644
index 0000000000..4481c72cf7
--- /dev/null
+++ b/web/app/components/workflow/header/__tests__/view-history.spec.tsx
@@ -0,0 +1,276 @@
+import type { WorkflowRunHistory, WorkflowRunHistoryResponse } from '@/types/workflow'
+import { fireEvent, screen } from '@testing-library/react'
+import * as React from 'react'
+import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
+import { ControlMode, WorkflowRunningStatus } from '../../types'
+import ViewHistory from '../view-history'
+
+const mockUseWorkflowRunHistory = vi.fn()
+const mockFormatTimeFromNow = vi.fn((value: number) => `from-now:${value}`)
+const mockCloseAllInputFieldPanels = vi.fn()
+const mockHandleNodesCancelSelected = vi.fn()
+const mockHandleCancelDebugAndPreviewPanel = vi.fn()
+const mockFormatWorkflowRunIdentifier = vi.fn((finishedAt?: number, status?: string) => ` (${status || finishedAt || 'unknown'})`)
+
+let mockIsChatMode = false
+
+vi.mock('../../hooks', async () => {
+ const actual = await vi.importActual('../../hooks')
+ return {
+ ...actual,
+ useIsChatMode: () => mockIsChatMode,
+ useNodesInteractions: () => ({
+ handleNodesCancelSelected: mockHandleNodesCancelSelected,
+ }),
+ useWorkflowInteractions: () => ({
+ handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel,
+ }),
+ }
+})
+
+vi.mock('@/service/use-workflow', () => ({
+ useWorkflowRunHistory: (url?: string, enabled?: boolean) => mockUseWorkflowRunHistory(url, enabled),
+}))
+
+vi.mock('@/hooks/use-format-time-from-now', () => ({
+ useFormatTimeFromNow: () => ({
+ formatTimeFromNow: mockFormatTimeFromNow,
+ }),
+}))
+
+vi.mock('@/app/components/rag-pipeline/hooks', () => ({
+ useInputFieldPanel: () => ({
+ closeAllInputFieldPanels: mockCloseAllInputFieldPanels,
+ }),
+}))
+
+vi.mock('@/app/components/base/loading', () => ({
+ default: () => ,
+}))
+
+vi.mock('@/app/components/base/tooltip', () => ({
+ default: ({ children }: { children?: React.ReactNode }) => <>{children}>,
+}))
+
+vi.mock('@/app/components/base/portal-to-follow-elem', () => {
+ const PortalContext = React.createContext({ open: false })
+
+ return {
+ PortalToFollowElem: ({
+ children,
+ open,
+ }: {
+ children?: React.ReactNode
+ open: boolean
+ }) => {children},
+ PortalToFollowElemTrigger: ({
+ children,
+ onClick,
+ }: {
+ children?: React.ReactNode
+ onClick?: () => void
+ }) => {children}
,
+ PortalToFollowElemContent: ({
+ children,
+ }: {
+ children?: React.ReactNode
+ }) => {
+ const { open } = React.useContext(PortalContext)
+ return open ? {children}
: null
+ },
+ }
+})
+
+vi.mock('../../utils', async () => {
+ const actual = await vi.importActual('../../utils')
+ return {
+ ...actual,
+ formatWorkflowRunIdentifier: (finishedAt?: number, status?: string) => mockFormatWorkflowRunIdentifier(finishedAt, status),
+ }
+})
+
+const createHistoryItem = (overrides: Partial = {}): WorkflowRunHistory => ({
+ id: 'run-1',
+ version: 'v1',
+ graph: {
+ nodes: [],
+ edges: [],
+ },
+ inputs: {},
+ status: WorkflowRunningStatus.Succeeded,
+ outputs: {},
+ elapsed_time: 1,
+ total_tokens: 2,
+ total_steps: 3,
+ created_at: 100,
+ finished_at: 120,
+ created_by_account: {
+ id: 'user-1',
+ name: 'Alice',
+ email: 'alice@example.com',
+ },
+ ...overrides,
+})
+
+describe('ViewHistory', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockIsChatMode = false
+ mockUseWorkflowRunHistory.mockReturnValue({
+ data: { data: [] } satisfies WorkflowRunHistoryResponse,
+ isLoading: false,
+ })
+ })
+
+ it('defers fetching until the history popup is opened and renders the empty state', () => {
+ renderWorkflowComponent(, {
+ hooksStoreProps: {
+ handleBackupDraft: vi.fn(),
+ },
+ })
+
+ expect(mockUseWorkflowRunHistory).toHaveBeenCalledWith('/history', false)
+ expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
+
+ expect(mockUseWorkflowRunHistory).toHaveBeenLastCalledWith('/history', true)
+ expect(screen.getByText('workflow.common.notRunning')).toBeInTheDocument()
+ expect(screen.getByText('workflow.common.showRunHistory')).toBeInTheDocument()
+ })
+
+ it('renders the icon trigger variant and loading state, and clears log modals on trigger click', () => {
+ const onClearLogAndMessageModal = vi.fn()
+ mockUseWorkflowRunHistory.mockReturnValue({
+ data: { data: [] } satisfies WorkflowRunHistoryResponse,
+ isLoading: true,
+ })
+
+ renderWorkflowComponent(
+ ,
+ {
+ hooksStoreProps: {
+ handleBackupDraft: vi.fn(),
+ },
+ },
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'workflow.common.viewRunHistory' }))
+
+ expect(onClearLogAndMessageModal).toHaveBeenCalledTimes(1)
+ expect(screen.getByTestId('loading')).toBeInTheDocument()
+ })
+
+ it('renders workflow run history items and updates the workflow store when one is selected', () => {
+ const handleBackupDraft = vi.fn()
+ const pausedRun = createHistoryItem({
+ id: 'run-paused',
+ status: WorkflowRunningStatus.Paused,
+ created_at: 101,
+ finished_at: 0,
+ })
+ const failedRun = createHistoryItem({
+ id: 'run-failed',
+ status: WorkflowRunningStatus.Failed,
+ created_at: 102,
+ finished_at: 130,
+ })
+ const succeededRun = createHistoryItem({
+ id: 'run-succeeded',
+ status: WorkflowRunningStatus.Succeeded,
+ created_at: 103,
+ finished_at: 140,
+ })
+
+ mockUseWorkflowRunHistory.mockReturnValue({
+ data: {
+ data: [pausedRun, failedRun, succeededRun],
+ } satisfies WorkflowRunHistoryResponse,
+ isLoading: false,
+ })
+
+ const { store } = renderWorkflowComponent(, {
+ initialStoreState: {
+ historyWorkflowData: failedRun,
+ showInputsPanel: true,
+ showEnvPanel: true,
+ controlMode: ControlMode.Pointer,
+ },
+ hooksStoreProps: {
+ handleBackupDraft,
+ },
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
+
+ expect(screen.getByText('Test Run (paused)')).toBeInTheDocument()
+ expect(screen.getByText('Test Run (failed)')).toBeInTheDocument()
+ expect(screen.getByText('Test Run (succeeded)')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByText('Test Run (succeeded)'))
+
+ expect(store.getState().historyWorkflowData).toEqual(succeededRun)
+ expect(store.getState().showInputsPanel).toBe(false)
+ expect(store.getState().showEnvPanel).toBe(false)
+ expect(store.getState().controlMode).toBe(ControlMode.Hand)
+ expect(mockCloseAllInputFieldPanels).toHaveBeenCalledTimes(1)
+ expect(handleBackupDraft).toHaveBeenCalledTimes(1)
+ expect(mockHandleNodesCancelSelected).toHaveBeenCalledTimes(1)
+ expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1)
+ })
+
+ it('renders chat history labels without workflow status icons in chat mode', () => {
+ mockIsChatMode = true
+ const chatRun = createHistoryItem({
+ id: 'chat-run',
+ status: WorkflowRunningStatus.Failed,
+ })
+
+ mockUseWorkflowRunHistory.mockReturnValue({
+ data: {
+ data: [chatRun],
+ } satisfies WorkflowRunHistoryResponse,
+ isLoading: false,
+ })
+
+ renderWorkflowComponent(, {
+ hooksStoreProps: {
+ handleBackupDraft: vi.fn(),
+ },
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
+
+ expect(screen.getByText('Test Chat (failed)')).toBeInTheDocument()
+ })
+
+ it('closes the popup from the close button and clears log modals', () => {
+ const onClearLogAndMessageModal = vi.fn()
+ mockUseWorkflowRunHistory.mockReturnValue({
+ data: { data: [] } satisfies WorkflowRunHistoryResponse,
+ isLoading: false,
+ })
+
+ renderWorkflowComponent(
+ ,
+ {
+ hooksStoreProps: {
+ handleBackupDraft: vi.fn(),
+ },
+ },
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'workflow.common.showRunHistory' }))
+ fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
+
+ expect(onClearLogAndMessageModal).toHaveBeenCalledTimes(1)
+ expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/workflow/header/scroll-to-selected-node-button.tsx b/web/app/components/workflow/header/scroll-to-selected-node-button.tsx
index 5c9df54fb6..c7a1e97964 100644
--- a/web/app/components/workflow/header/scroll-to-selected-node-button.tsx
+++ b/web/app/components/workflow/header/scroll-to-selected-node-button.tsx
@@ -1,6 +1,5 @@
import type { FC } from 'react'
import type { CommonNodeType } from '../types'
-import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useNodes } from 'reactflow'
import { cn } from '@/utils/classnames'
@@ -11,21 +10,15 @@ const ScrollToSelectedNodeButton: FC = () => {
const nodes = useNodes()
const selectedNode = nodes.find(node => node.data.selected)
- const handleScrollToSelectedNode = useCallback(() => {
- if (!selectedNode)
- return
- scrollToWorkflowNode(selectedNode.id)
- }, [selectedNode])
-
if (!selectedNode)
return null
return (
scrollToWorkflowNode(selectedNode.id)}
>
{t('panel.scrollToSelectedNode', { ns: 'workflow' })}
diff --git a/web/app/components/workflow/header/undo-redo.tsx b/web/app/components/workflow/header/undo-redo.tsx
index a90720aeb1..c6b91972c9 100644
--- a/web/app/components/workflow/header/undo-redo.tsx
+++ b/web/app/components/workflow/header/undo-redo.tsx
@@ -1,8 +1,4 @@
import type { FC } from 'react'
-import {
- RiArrowGoBackLine,
- RiArrowGoForwardFill,
-} from '@remixicon/react'
import { memo, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ViewWorkflowHistory from '@/app/components/workflow/header/view-workflow-history'
@@ -33,28 +29,34 @@ const UndoRedo: FC = ({ handleUndo, handleRedo }) => {
return (
- !nodesReadOnly && !buttonsDisabled.undo && handleUndo()}
+ onClick={handleUndo}
>
-
-
+
+
- !nodesReadOnly && !buttonsDisabled.redo && handleRedo()}
+ onClick={handleRedo}
>
-
-
+
+
diff --git a/web/app/components/workflow/header/view-history.tsx b/web/app/components/workflow/header/view-history.tsx
index 94963e29fc..162d46f8fe 100644
--- a/web/app/components/workflow/header/view-history.tsx
+++ b/web/app/components/workflow/header/view-history.tsx
@@ -73,15 +73,18 @@ const ViewHistory = ({
setOpen(v => !v)}>
{
withText && (
-
{t('common.showRunHistory', { ns: 'workflow' })}
-
+
)
}
{
@@ -89,14 +92,16 @@ const ViewHistory = ({
- {
onClearLogAndMessageModal?.()
}}
>
-
+
)
}
@@ -110,7 +115,9 @@ const ViewHistory = ({
>
{t('common.runHistory', { ns: 'workflow' })}
-
{
onClearLogAndMessageModal?.()
@@ -118,7 +125,7 @@ const ViewHistory = ({
}}
>
-
+
{
isLoading && (
diff --git a/web/app/components/workflow/nodes/_base/components/node-control.spec.tsx b/web/app/components/workflow/nodes/_base/components/node-control.spec.tsx
index a76eba69ef..1843f77a52 100644
--- a/web/app/components/workflow/nodes/_base/components/node-control.spec.tsx
+++ b/web/app/components/workflow/nodes/_base/components/node-control.spec.tsx
@@ -1,54 +1,36 @@
import type { CommonNodeType } from '../../../types'
-import { fireEvent, render, screen } from '@testing-library/react'
+import { fireEvent, screen } from '@testing-library/react'
+import { renderWorkflowComponent } from '../../../__tests__/workflow-test-env'
import { BlockEnum, NodeRunningStatus } from '../../../types'
import NodeControl from './node-control'
const {
mockHandleNodeSelect,
- mockSetInitShowLastRunTab,
- mockSetPendingSingleRun,
mockCanRunBySingle,
} = vi.hoisted(() => ({
mockHandleNodeSelect: vi.fn(),
- mockSetInitShowLastRunTab: vi.fn(),
- mockSetPendingSingleRun: vi.fn(),
mockCanRunBySingle: vi.fn(() => true),
}))
-vi.mock('react-i18next', () => ({
- useTranslation: () => ({
- t: (key: string) => key,
- }),
-}))
+let mockPluginInstallLocked = false
-vi.mock('@/app/components/base/tooltip', () => ({
- default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
- {children}
- ),
-}))
-
-vi.mock('@/app/components/base/icons/src/vender/line/mediaAndDevices', () => ({
- Stop: ({ className }: { className?: string }) => ,
-}))
-
-vi.mock('../../../hooks', () => ({
- useNodesInteractions: () => ({
- handleNodeSelect: mockHandleNodeSelect,
- }),
-}))
-
-vi.mock('@/app/components/workflow/store', () => ({
- useWorkflowStore: () => ({
- getState: () => ({
- setInitShowLastRunTab: mockSetInitShowLastRunTab,
- setPendingSingleRun: mockSetPendingSingleRun,
+vi.mock('../../../hooks', async () => {
+ const actual = await vi.importActual('../../../hooks')
+ return {
+ ...actual,
+ useNodesInteractions: () => ({
+ handleNodeSelect: mockHandleNodeSelect,
}),
- }),
-}))
+ }
+})
-vi.mock('../../../utils', () => ({
- canRunBySingle: mockCanRunBySingle,
-}))
+vi.mock('../../../utils', async () => {
+ const actual = await vi.importActual('../../../utils')
+ return {
+ ...actual,
+ canRunBySingle: mockCanRunBySingle,
+ }
+})
vi.mock('./panel-operator', () => ({
default: ({ onOpenChange }: { onOpenChange: (open: boolean) => void }) => (
@@ -59,6 +41,16 @@ vi.mock('./panel-operator', () => ({
),
}))
+function NodeControlHarness({ id, data }: { id: string, data: CommonNodeType, selected?: boolean }) {
+ return (
+
+ )
+}
+
const makeData = (overrides: Partial = {}): CommonNodeType => ({
type: BlockEnum.Code,
title: 'Node',
@@ -73,65 +65,71 @@ const makeData = (overrides: Partial = {}): CommonNodeType => ({
describe('NodeControl', () => {
beforeEach(() => {
vi.clearAllMocks()
+ mockPluginInstallLocked = false
mockCanRunBySingle.mockReturnValue(true)
})
- it('should trigger a single run and show the hover control when plugins are not locked', () => {
- const { container } = render(
- ,
- )
+ // Run/stop behavior should be driven by the workflow store, not CSS classes.
+ describe('Single Run Actions', () => {
+ it('should trigger a single run through the workflow store', () => {
+ const { store } = renderWorkflowComponent(
+ ,
+ )
- const wrapper = container.firstChild as HTMLElement
- expect(wrapper.className).toContain('group-hover:flex')
- expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'panel.runThisStep')
+ fireEvent.click(screen.getByRole('button', { name: 'workflow.panel.runThisStep' }))
- fireEvent.click(screen.getByTestId('tooltip').parentElement!)
+ expect(store.getState().initShowLastRunTab).toBe(true)
+ expect(store.getState().pendingSingleRun).toEqual({ nodeId: 'node-1', action: 'run' })
+ expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1')
+ })
- expect(mockSetInitShowLastRunTab).toHaveBeenCalledWith(true)
- expect(mockSetPendingSingleRun).toHaveBeenCalledWith({ nodeId: 'node-1', action: 'run' })
- expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1')
+ it('should trigger stop when the node is already single-running', () => {
+ const { store } = renderWorkflowComponent(
+ ,
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'workflow.debug.variableInspect.trigger.stop' }))
+
+ expect(store.getState().pendingSingleRun).toEqual({ nodeId: 'node-2', action: 'stop' })
+ expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-2')
+ })
})
- it('should render the stop action, keep locked controls hidden by default, and stay open when panel operator opens', () => {
- const { container } = render(
- ,
- )
+ // Capability gating should hide the run control while leaving panel actions available.
+ describe('Availability', () => {
+ it('should keep the panel operator available when the plugin is install-locked', () => {
+ mockPluginInstallLocked = true
- const wrapper = container.firstChild as HTMLElement
- expect(wrapper.className).not.toContain('group-hover:flex')
- expect(wrapper.className).toContain('!flex')
- expect(screen.getByTestId('stop-icon')).toBeInTheDocument()
+ renderWorkflowComponent(
+ ,
+ )
- fireEvent.click(screen.getByTestId('stop-icon').parentElement!)
+ expect(screen.getByRole('button', { name: 'open panel' })).toBeInTheDocument()
+ })
- expect(mockSetPendingSingleRun).toHaveBeenCalledWith({ nodeId: 'node-2', action: 'stop' })
+ it('should hide the run control when single-node execution is not supported', () => {
+ mockCanRunBySingle.mockReturnValue(false)
- fireEvent.click(screen.getByRole('button', { name: 'open panel' }))
- expect(wrapper.className).toContain('!flex')
- })
+ renderWorkflowComponent(
+ ,
+ )
- it('should hide the run control when single-node execution is not supported', () => {
- mockCanRunBySingle.mockReturnValue(false)
-
- render(
- ,
- )
-
- expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument()
- expect(screen.getByRole('button', { name: 'open panel' })).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'workflow.panel.runThisStep' })).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'open panel' })).toBeInTheDocument()
+ })
})
})
diff --git a/web/app/components/workflow/nodes/_base/components/node-control.tsx b/web/app/components/workflow/nodes/_base/components/node-control.tsx
index 1ae697dfc4..ba2a4d3f73 100644
--- a/web/app/components/workflow/nodes/_base/components/node-control.tsx
+++ b/web/app/components/workflow/nodes/_base/components/node-control.tsx
@@ -1,8 +1,5 @@
import type { FC } from 'react'
import type { Node } from '../../../types'
-import {
- RiPlayLargeLine,
-} from '@remixicon/react'
import {
memo,
useCallback,
@@ -54,7 +51,9 @@ const NodeControl: FC = ({
>
{
canRunBySingle(data.type, isChildNode) && (
- {
const action = isSingleRunning ? 'stop' : 'run'
@@ -76,11 +75,11 @@ const NodeControl: FC = ({
popupContent={t('panel.runThisStep', { ns: 'workflow' })}
asChild={false}
>
-
+
)
}
-
+
)
}
({
mockHandleStatusCodeChange: vi.fn(),
mockGenerateWebhookUrl: vi.fn(),
+ mockHandleMethodChange: vi.fn(),
+ mockHandleContentTypeChange: vi.fn(),
+ mockHandleHeadersChange: vi.fn(),
+ mockHandleParamsChange: vi.fn(),
+ mockHandleBodyChange: vi.fn(),
+ mockHandleResponseBodyChange: vi.fn(),
}))
+const mockConfigState = {
+ readOnly: false,
+ inputs: {
+ method: 'POST',
+ webhook_url: 'https://example.com/webhook',
+ webhook_debug_url: '',
+ content_type: 'application/json',
+ headers: [],
+ params: [],
+ body: [],
+ status_code: 200,
+ response_body: 'ok',
+ variables: [],
+ },
+}
+
vi.mock('../use-config', () => ({
DEFAULT_STATUS_CODE: 200,
MAX_STATUS_CODE: 399,
normalizeStatusCode: (statusCode: number) => Math.min(Math.max(statusCode, 200), 399),
useConfig: () => ({
- readOnly: false,
- inputs: {
- method: 'POST',
- webhook_url: 'https://example.com/webhook',
- webhook_debug_url: '',
- content_type: 'application/json',
- headers: [],
- params: [],
- body: [],
- status_code: 200,
- response_body: '',
- },
- handleMethodChange: vi.fn(),
- handleContentTypeChange: vi.fn(),
- handleHeadersChange: vi.fn(),
- handleParamsChange: vi.fn(),
- handleBodyChange: vi.fn(),
+ readOnly: mockConfigState.readOnly,
+ inputs: mockConfigState.inputs,
+ handleMethodChange: mockHandleMethodChange,
+ handleContentTypeChange: mockHandleContentTypeChange,
+ handleHeadersChange: mockHandleHeadersChange,
+ handleParamsChange: mockHandleParamsChange,
+ handleBodyChange: mockHandleBodyChange,
handleStatusCodeChange: mockHandleStatusCodeChange,
- handleResponseBodyChange: vi.fn(),
+ handleResponseBodyChange: mockHandleResponseBodyChange,
generateWebhookUrl: mockGenerateWebhookUrl,
}),
}))
-vi.mock('@/app/components/base/input-with-copy', () => ({
- default: () => ,
-}))
-
-vi.mock('@/app/components/base/select', () => ({
- SimpleSelect: () => ,
-}))
-
-vi.mock('@/app/components/base/tooltip', () => ({
- default: ({ children }: { children: React.ReactNode }) => <>{children}>,
-}))
-
-vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({
- default: ({ title, children }: { title: React.ReactNode, children: React.ReactNode }) => (
-
- ),
-}))
-
-vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
- default: () => ,
-}))
-
-vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
- default: () => ,
-}))
-
-vi.mock('../components/header-table', () => ({
- default: () => ,
-}))
-
-vi.mock('../components/parameter-table', () => ({
- default: () => ,
-}))
-
-vi.mock('../components/paragraph-input', () => ({
- default: () => ,
-}))
-
-vi.mock('../utils/render-output-vars', () => ({
- OutputVariablesContent: () => ,
-}))
+const getStatusCodeInput = () => {
+ return screen.getAllByDisplayValue('200')
+ .find(element => element.getAttribute('aria-hidden') !== 'true') as HTMLInputElement
+}
describe('WebhookTriggerPanel', () => {
const panelProps: NodePanelProps = {
@@ -100,7 +78,7 @@ describe('WebhookTriggerPanel', () => {
body: [],
async_mode: false,
status_code: 200,
- response_body: '',
+ response_body: 'ok',
variables: [],
},
panelProps: {} as PanelProps,
@@ -108,26 +86,65 @@ describe('WebhookTriggerPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
+ mockConfigState.readOnly = false
+ mockConfigState.inputs = {
+ method: 'POST',
+ webhook_url: 'https://example.com/webhook',
+ webhook_debug_url: '',
+ content_type: 'application/json',
+ headers: [],
+ params: [],
+ body: [],
+ status_code: 200,
+ response_body: 'ok',
+ variables: [],
+ }
})
- it('should update the status code when users enter a parseable value', () => {
- render()
+ describe('Rendering', () => {
+ it('should render the real panel fields without generating a new webhook url when one already exists', () => {
+ render()
- fireEvent.change(screen.getByRole('textbox'), { target: { value: '201' } })
+ expect(screen.getByDisplayValue('https://example.com/webhook')).toBeInTheDocument()
+ expect(screen.getByText('application/json')).toBeInTheDocument()
+ expect(screen.getByDisplayValue('ok')).toBeInTheDocument()
+ expect(mockGenerateWebhookUrl).not.toHaveBeenCalled()
+ })
- expect(mockHandleStatusCodeChange).toHaveBeenCalledWith(201)
+ it('should request a webhook url when the node is writable and missing one', async () => {
+ mockConfigState.inputs = {
+ ...mockConfigState.inputs,
+ webhook_url: '',
+ }
+
+ render()
+
+ await waitFor(() => {
+ expect(mockGenerateWebhookUrl).toHaveBeenCalledTimes(1)
+ })
+ })
})
- it('should ignore clear changes until the value is committed', () => {
- render()
+ describe('Status Code Input', () => {
+ it('should update the status code when users enter a parseable value', () => {
+ render()
- const input = screen.getByRole('textbox')
- fireEvent.change(input, { target: { value: '' } })
+ fireEvent.change(getStatusCodeInput(), { target: { value: '201' } })
- expect(mockHandleStatusCodeChange).not.toHaveBeenCalled()
+ expect(mockHandleStatusCodeChange).toHaveBeenCalledWith(201)
+ })
- fireEvent.blur(input)
+ it('should ignore clear changes until the value is committed', () => {
+ render()
- expect(mockHandleStatusCodeChange).toHaveBeenCalledWith(200)
+ const input = getStatusCodeInput()
+ fireEvent.change(input, { target: { value: '' } })
+
+ expect(mockHandleStatusCodeChange).not.toHaveBeenCalled()
+
+ fireEvent.blur(input)
+
+ expect(mockHandleStatusCodeChange).toHaveBeenCalledWith(200)
+ })
})
})
diff --git a/web/app/components/workflow/operator/__tests__/add-block.spec.tsx b/web/app/components/workflow/operator/__tests__/add-block.spec.tsx
new file mode 100644
index 0000000000..ab7ec2ef0e
--- /dev/null
+++ b/web/app/components/workflow/operator/__tests__/add-block.spec.tsx
@@ -0,0 +1,225 @@
+import type { ReactNode } from 'react'
+import { act, render, screen, waitFor } from '@testing-library/react'
+import ReactFlow, { ReactFlowProvider } from 'reactflow'
+import { FlowType } from '@/types/common'
+import { BlockEnum } from '../../types'
+import AddBlock from '../add-block'
+
+type BlockSelectorMockProps = {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ disabled: boolean
+ onSelect: (type: BlockEnum, pluginDefaultValue?: Record) => void
+ placement: string
+ offset: {
+ mainAxis: number
+ crossAxis: number
+ }
+ trigger: (open: boolean) => ReactNode
+ popupClassName: string
+ availableBlocksTypes: BlockEnum[]
+ showStartTab: boolean
+}
+
+const {
+ mockHandlePaneContextmenuCancel,
+ mockWorkflowStoreSetState,
+ mockGenerateNewNode,
+ mockGetNodeCustomTypeByNodeDataType,
+} = vi.hoisted(() => ({
+ mockHandlePaneContextmenuCancel: vi.fn(),
+ mockWorkflowStoreSetState: vi.fn(),
+ mockGenerateNewNode: vi.fn(({ type, data }: { type: string, data: Record }) => ({
+ newNode: {
+ id: 'generated-node',
+ type,
+ data,
+ },
+ })),
+ mockGetNodeCustomTypeByNodeDataType: vi.fn((type: string) => `${type}-custom`),
+}))
+
+let latestBlockSelectorProps: BlockSelectorMockProps | null = null
+let mockNodesReadOnly = false
+let mockIsChatMode = false
+let mockFlowType: FlowType = FlowType.appFlow
+
+const mockAvailableNextBlocks = [BlockEnum.Answer, BlockEnum.Code]
+const mockNodesMetaDataMap = {
+ [BlockEnum.Answer]: {
+ defaultValue: {
+ title: 'Answer',
+ desc: '',
+ type: BlockEnum.Answer,
+ },
+ },
+}
+
+vi.mock('@/app/components/workflow/block-selector', () => ({
+ default: (props: BlockSelectorMockProps) => {
+ latestBlockSelectorProps = props
+ return (
+
+ {props.trigger(props.open)}
+
+ )
+ },
+}))
+
+vi.mock('../../hooks', () => ({
+ useAvailableBlocks: () => ({
+ availableNextBlocks: mockAvailableNextBlocks,
+ }),
+ useIsChatMode: () => mockIsChatMode,
+ useNodesMetaData: () => ({
+ nodesMap: mockNodesMetaDataMap,
+ }),
+ useNodesReadOnly: () => ({
+ nodesReadOnly: mockNodesReadOnly,
+ }),
+ usePanelInteractions: () => ({
+ handlePaneContextmenuCancel: mockHandlePaneContextmenuCancel,
+ }),
+}))
+
+vi.mock('../../hooks-store', () => ({
+ useHooksStore: (selector: (state: { configsMap?: { flowType?: FlowType } }) => unknown) =>
+ selector({ configsMap: { flowType: mockFlowType } }),
+}))
+
+vi.mock('../../store', () => ({
+ useWorkflowStore: () => ({
+ setState: mockWorkflowStoreSetState,
+ }),
+}))
+
+vi.mock('../../utils', () => ({
+ generateNewNode: mockGenerateNewNode,
+ getNodeCustomTypeByNodeDataType: mockGetNodeCustomTypeByNodeDataType,
+}))
+
+vi.mock('../tip-popup', () => ({
+ default: ({ children }: { children?: ReactNode }) => <>{children}>,
+}))
+
+const renderWithReactFlow = (nodes: Array<{ id: string, position: { x: number, y: number }, data: { type: BlockEnum } }>) => {
+ return render(
+ ,
+ )
+}
+
+describe('AddBlock', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ latestBlockSelectorProps = null
+ mockNodesReadOnly = false
+ mockIsChatMode = false
+ mockFlowType = FlowType.appFlow
+ })
+
+ // Rendering and selector configuration.
+ describe('Rendering', () => {
+ it('should pass the selector props for a writable app workflow', async () => {
+ renderWithReactFlow([])
+
+ await waitFor(() => expect(latestBlockSelectorProps).not.toBeNull())
+
+ expect(screen.getByTestId('block-selector')).toBeInTheDocument()
+ expect(latestBlockSelectorProps).toMatchObject({
+ disabled: false,
+ availableBlocksTypes: mockAvailableNextBlocks,
+ showStartTab: true,
+ placement: 'right-start',
+ popupClassName: '!min-w-[256px]',
+ })
+ expect(latestBlockSelectorProps?.offset).toEqual({
+ mainAxis: 4,
+ crossAxis: -8,
+ })
+ })
+
+ it('should hide the start tab for chat mode and rag pipeline flows', async () => {
+ mockIsChatMode = true
+ const { rerender } = renderWithReactFlow([])
+
+ await waitFor(() => expect(latestBlockSelectorProps).not.toBeNull())
+
+ expect(latestBlockSelectorProps?.showStartTab).toBe(false)
+
+ mockIsChatMode = false
+ mockFlowType = FlowType.ragPipeline
+ rerender(
+ ,
+ )
+
+ expect(latestBlockSelectorProps?.showStartTab).toBe(false)
+ })
+ })
+
+ // User interactions that bridge selector state and workflow state.
+ describe('User Interactions', () => {
+ it('should cancel the pane context menu when the selector closes', async () => {
+ renderWithReactFlow([])
+
+ await waitFor(() => expect(latestBlockSelectorProps).not.toBeNull())
+
+ act(() => {
+ latestBlockSelectorProps?.onOpenChange(false)
+ })
+
+ expect(mockHandlePaneContextmenuCancel).toHaveBeenCalledTimes(1)
+ })
+
+ it('should create a candidate node with an incremented title when a block is selected', async () => {
+ renderWithReactFlow([
+ { id: 'node-1', position: { x: 0, y: 0 }, data: { type: BlockEnum.Answer } },
+ { id: 'node-2', position: { x: 80, y: 0 }, data: { type: BlockEnum.Answer } },
+ ])
+
+ await waitFor(() => expect(latestBlockSelectorProps).not.toBeNull())
+
+ act(() => {
+ latestBlockSelectorProps?.onSelect(BlockEnum.Answer, { pluginId: 'plugin-1' })
+ })
+
+ expect(mockGetNodeCustomTypeByNodeDataType).toHaveBeenCalledWith(BlockEnum.Answer)
+ expect(mockGenerateNewNode).toHaveBeenCalledWith({
+ type: 'answer-custom',
+ data: {
+ title: 'Answer 3',
+ desc: '',
+ type: BlockEnum.Answer,
+ pluginId: 'plugin-1',
+ _isCandidate: true,
+ },
+ position: {
+ x: 0,
+ y: 0,
+ },
+ })
+ expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({
+ candidateNode: {
+ id: 'generated-node',
+ type: 'answer-custom',
+ data: {
+ title: 'Answer 3',
+ desc: '',
+ type: BlockEnum.Answer,
+ pluginId: 'plugin-1',
+ _isCandidate: true,
+ },
+ },
+ })
+ })
+ })
+})
diff --git a/web/app/components/workflow/operator/__tests__/control.spec.tsx b/web/app/components/workflow/operator/__tests__/control.spec.tsx
new file mode 100644
index 0000000000..053d61d1ce
--- /dev/null
+++ b/web/app/components/workflow/operator/__tests__/control.spec.tsx
@@ -0,0 +1,136 @@
+import type { ReactNode } from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { ControlMode } from '../../types'
+import Control from '../control'
+
+type WorkflowStoreState = {
+ controlMode: ControlMode
+ maximizeCanvas: boolean
+}
+
+const {
+ mockHandleAddNote,
+ mockHandleLayout,
+ mockHandleModeHand,
+ mockHandleModePointer,
+ mockHandleToggleMaximizeCanvas,
+} = vi.hoisted(() => ({
+ mockHandleAddNote: vi.fn(),
+ mockHandleLayout: vi.fn(),
+ mockHandleModeHand: vi.fn(),
+ mockHandleModePointer: vi.fn(),
+ mockHandleToggleMaximizeCanvas: vi.fn(),
+}))
+
+let mockNodesReadOnly = false
+let mockStoreState: WorkflowStoreState
+
+vi.mock('../../hooks', () => ({
+ useNodesReadOnly: () => ({
+ nodesReadOnly: mockNodesReadOnly,
+ getNodesReadOnly: () => mockNodesReadOnly,
+ }),
+ useWorkflowCanvasMaximize: () => ({
+ handleToggleMaximizeCanvas: mockHandleToggleMaximizeCanvas,
+ }),
+ useWorkflowMoveMode: () => ({
+ handleModePointer: mockHandleModePointer,
+ handleModeHand: mockHandleModeHand,
+ }),
+ useWorkflowOrganize: () => ({
+ handleLayout: mockHandleLayout,
+ }),
+}))
+
+vi.mock('../hooks', () => ({
+ useOperator: () => ({
+ handleAddNote: mockHandleAddNote,
+ }),
+}))
+
+vi.mock('../../store', () => ({
+ useStore: (selector: (state: WorkflowStoreState) => unknown) => selector(mockStoreState),
+}))
+
+vi.mock('../add-block', () => ({
+ default: () => ,
+}))
+
+vi.mock('../more-actions', () => ({
+ default: () => ,
+}))
+
+vi.mock('../tip-popup', () => ({
+ default: ({
+ children,
+ title,
+ }: {
+ children?: ReactNode
+ title?: string
+ }) => {children}
,
+}))
+
+describe('Control', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockNodesReadOnly = false
+ mockStoreState = {
+ controlMode: ControlMode.Pointer,
+ maximizeCanvas: false,
+ }
+ })
+
+ // Rendering and visual states for control buttons.
+ describe('Rendering', () => {
+ it('should render the child action groups and highlight the active pointer mode', () => {
+ render()
+
+ expect(screen.getByTestId('add-block')).toBeInTheDocument()
+ expect(screen.getByTestId('more-actions')).toBeInTheDocument()
+ expect(screen.getByTestId('workflow.common.pointerMode').firstElementChild).toHaveClass('bg-state-accent-active')
+ expect(screen.getByTestId('workflow.common.handMode').firstElementChild).not.toHaveClass('bg-state-accent-active')
+ expect(screen.getByTestId('workflow.panel.maximize')).toBeInTheDocument()
+ })
+
+ it('should switch the maximize tooltip and active style when the canvas is maximized', () => {
+ mockStoreState = {
+ controlMode: ControlMode.Hand,
+ maximizeCanvas: true,
+ }
+
+ render()
+
+ expect(screen.getByTestId('workflow.common.handMode').firstElementChild).toHaveClass('bg-state-accent-active')
+ expect(screen.getByTestId('workflow.panel.minimize').firstElementChild).toHaveClass('bg-state-accent-active')
+ })
+ })
+
+ // User interactions exposed by the control bar.
+ describe('User Interactions', () => {
+ it('should trigger the note, mode, organize, and maximize handlers', () => {
+ render()
+
+ fireEvent.click(screen.getByTestId('workflow.nodes.note.addNote').firstElementChild as HTMLElement)
+ fireEvent.click(screen.getByTestId('workflow.common.pointerMode').firstElementChild as HTMLElement)
+ fireEvent.click(screen.getByTestId('workflow.common.handMode').firstElementChild as HTMLElement)
+ fireEvent.click(screen.getByTestId('workflow.panel.organizeBlocks').firstElementChild as HTMLElement)
+ fireEvent.click(screen.getByTestId('workflow.panel.maximize').firstElementChild as HTMLElement)
+
+ expect(mockHandleAddNote).toHaveBeenCalledTimes(1)
+ expect(mockHandleModePointer).toHaveBeenCalledTimes(1)
+ expect(mockHandleModeHand).toHaveBeenCalledTimes(1)
+ expect(mockHandleLayout).toHaveBeenCalledTimes(1)
+ expect(mockHandleToggleMaximizeCanvas).toHaveBeenCalledTimes(1)
+ })
+
+ it('should block note creation when the workflow is read only', () => {
+ mockNodesReadOnly = true
+
+ render()
+
+ fireEvent.click(screen.getByTestId('workflow.nodes.note.addNote').firstElementChild as HTMLElement)
+
+ expect(mockHandleAddNote).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/web/app/components/workflow/panel/__tests__/inputs-panel.spec.tsx b/web/app/components/workflow/panel/__tests__/inputs-panel.spec.tsx
new file mode 100644
index 0000000000..ddefe60b7e
--- /dev/null
+++ b/web/app/components/workflow/panel/__tests__/inputs-panel.spec.tsx
@@ -0,0 +1,323 @@
+import type { Shape as HooksStoreShape } from '../../hooks-store/store'
+import type { RunFile } from '../../types'
+import type { FileUpload } from '@/app/components/base/features/types'
+import { screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import ReactFlow, { ReactFlowProvider } from 'reactflow'
+import { TransferMethod } from '@/types/app'
+import { FlowType } from '@/types/common'
+import { createStartNode } from '../../__tests__/fixtures'
+import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
+import { InputVarType, WorkflowRunningStatus } from '../../types'
+import InputsPanel from '../inputs-panel'
+
+const mockCheckInputsForm = vi.fn()
+const mockNotify = vi.fn()
+
+vi.mock('next/navigation', () => ({
+ useParams: () => ({}),
+}))
+
+vi.mock('@/app/components/base/toast/context', () => ({
+ useToastContext: () => ({
+ notify: mockNotify,
+ close: vi.fn(),
+ }),
+}))
+
+vi.mock('@/app/components/base/chat/chat/check-input-forms-hooks', () => ({
+ useCheckInputsForms: () => ({
+ checkInputsForm: mockCheckInputsForm,
+ }),
+}))
+
+const fileSettingsWithImage = {
+ enabled: true,
+ image: {
+ enabled: true,
+ },
+ allowed_file_upload_methods: [TransferMethod.remote_url],
+ number_limits: 3,
+ image_file_size_limit: 10,
+} satisfies FileUpload & { image_file_size_limit: number }
+
+const uploadedRunFile = {
+ transfer_method: TransferMethod.remote_url,
+ upload_file_id: 'file-2',
+} as unknown as RunFile
+
+const uploadingRunFile = {
+ transfer_method: TransferMethod.local_file,
+} as unknown as RunFile
+
+const createHooksStoreProps = (
+ overrides: Partial = {},
+): Partial => ({
+ handleRun: vi.fn(),
+ configsMap: {
+ flowId: 'flow-1',
+ flowType: FlowType.appFlow,
+ fileSettings: fileSettingsWithImage,
+ },
+ ...overrides,
+})
+
+const renderInputsPanel = (
+ startNode: ReturnType,
+ options?: Parameters[1],
+) => {
+ return renderWorkflowComponent(
+
+
+
+
+
+
,
+ options,
+ )
+}
+
+describe('InputsPanel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCheckInputsForm.mockReturnValue(true)
+ })
+
+ describe('Rendering', () => {
+ it('should render current inputs, defaults, and the image uploader from the start node', () => {
+ renderInputsPanel(
+ createStartNode({
+ data: {
+ variables: [
+ {
+ type: InputVarType.textInput,
+ variable: 'question',
+ label: 'Question',
+ required: true,
+ default: 'default question',
+ },
+ {
+ type: InputVarType.number,
+ variable: 'count',
+ label: 'Count',
+ required: false,
+ default: '2',
+ },
+ ],
+ },
+ }),
+ {
+ initialStoreState: {
+ inputs: {
+ question: 'overridden question',
+ },
+ },
+ hooksStoreProps: createHooksStoreProps(),
+ },
+ )
+
+ expect(screen.getByDisplayValue('overridden question')).toHaveFocus()
+ expect(screen.getByRole('spinbutton')).toHaveValue(2)
+ expect(screen.getByText('common.imageUploader.pasteImageLink')).toBeInTheDocument()
+ })
+ })
+
+ describe('User Interactions', () => {
+ it('should update workflow inputs and image files when users edit the form', async () => {
+ const user = userEvent.setup()
+ const { store } = renderInputsPanel(
+ createStartNode({
+ data: {
+ variables: [
+ {
+ type: InputVarType.textInput,
+ variable: 'question',
+ label: 'Question',
+ required: true,
+ },
+ ],
+ },
+ }),
+ {
+ hooksStoreProps: createHooksStoreProps(),
+ },
+ )
+
+ await user.type(screen.getByPlaceholderText('Question'), 'changed question')
+ expect(store.getState().inputs).toEqual({ question: 'changed question' })
+
+ await user.click(screen.getByText('common.imageUploader.pasteImageLink'))
+ await user.type(
+ await screen.findByPlaceholderText('common.imageUploader.pasteImageLinkInputPlaceholder'),
+ 'https://example.com/image.png',
+ )
+ await user.click(screen.getByRole('button', { name: 'common.operation.ok' }))
+
+ await waitFor(() => {
+ expect(store.getState().files).toEqual([{
+ type: 'image',
+ transfer_method: TransferMethod.remote_url,
+ url: 'https://example.com/image.png',
+ upload_file_id: '',
+ }])
+ })
+ })
+
+ it('should not start a run when input validation fails', async () => {
+ const user = userEvent.setup()
+ mockCheckInputsForm.mockReturnValue(false)
+ const onRun = vi.fn()
+ const handleRun = vi.fn()
+
+ renderWorkflowComponent(
+
+
+
+
+
+
,
+ {
+ hooksStoreProps: createHooksStoreProps({ handleRun }),
+ },
+ )
+
+ await user.click(screen.getByRole('button', { name: 'workflow.singleRun.startRun' }))
+
+ expect(mockCheckInputsForm).toHaveBeenCalledWith(
+ { question: 'default question' },
+ expect.arrayContaining([
+ expect.objectContaining({ variable: 'question' }),
+ expect.objectContaining({ variable: '__image' }),
+ ]),
+ )
+ expect(onRun).not.toHaveBeenCalled()
+ expect(handleRun).not.toHaveBeenCalled()
+ })
+
+ it('should start a run with processed inputs when validation succeeds', async () => {
+ const user = userEvent.setup()
+ const onRun = vi.fn()
+ const handleRun = vi.fn()
+
+ renderWorkflowComponent(
+
+
+
+
+
+
,
+ {
+ initialStoreState: {
+ inputs: {
+ question: 'run this',
+ confirmed: 'truthy',
+ },
+ files: [uploadedRunFile],
+ },
+ hooksStoreProps: createHooksStoreProps({
+ handleRun,
+ configsMap: {
+ flowId: 'flow-1',
+ flowType: FlowType.appFlow,
+ fileSettings: {
+ enabled: false,
+ },
+ },
+ }),
+ },
+ )
+
+ await user.click(screen.getByRole('button', { name: 'workflow.singleRun.startRun' }))
+
+ expect(onRun).toHaveBeenCalledTimes(1)
+ expect(handleRun).toHaveBeenCalledWith({
+ inputs: {
+ question: 'run this',
+ confirmed: true,
+ },
+ files: [uploadedRunFile],
+ })
+ })
+ })
+
+ describe('Disabled States', () => {
+ it('should disable the run button while a local file is still uploading', () => {
+ renderInputsPanel(createStartNode(), {
+ initialStoreState: {
+ files: [uploadingRunFile],
+ },
+ hooksStoreProps: createHooksStoreProps({
+ configsMap: {
+ flowId: 'flow-1',
+ flowType: FlowType.appFlow,
+ fileSettings: {
+ enabled: false,
+ },
+ },
+ }),
+ })
+
+ expect(screen.getByRole('button', { name: 'workflow.singleRun.startRun' })).toBeDisabled()
+ })
+
+ it('should disable the run button while the workflow is already running', () => {
+ renderInputsPanel(createStartNode(), {
+ initialStoreState: {
+ workflowRunningData: {
+ result: {
+ status: WorkflowRunningStatus.Running,
+ inputs_truncated: false,
+ process_data_truncated: false,
+ outputs_truncated: false,
+ },
+ tracing: [],
+ },
+ },
+ hooksStoreProps: createHooksStoreProps(),
+ })
+
+ expect(screen.getByRole('button', { name: 'workflow.singleRun.startRun' })).toBeDisabled()
+ })
+ })
+})
diff --git a/web/app/components/workflow/panel/__tests__/record.spec.tsx b/web/app/components/workflow/panel/__tests__/record.spec.tsx
new file mode 100644
index 0000000000..1d07098427
--- /dev/null
+++ b/web/app/components/workflow/panel/__tests__/record.spec.tsx
@@ -0,0 +1,163 @@
+import type { WorkflowRunDetailResponse } from '@/models/log'
+import { act, screen } from '@testing-library/react'
+import { createEdge, createNode } from '../../__tests__/fixtures'
+import { renderWorkflowComponent } from '../../__tests__/workflow-test-env'
+import Record from '../record'
+
+const mockHandleUpdateWorkflowCanvas = vi.fn()
+const mockFormatWorkflowRunIdentifier = vi.fn((finishedAt?: number) => finishedAt ? ' (Finished)' : ' (Running)')
+
+let latestGetResultCallback: ((res: WorkflowRunDetailResponse) => void) | undefined
+
+vi.mock('@/app/components/workflow/hooks', () => ({
+ useWorkflowUpdate: () => ({
+ handleUpdateWorkflowCanvas: mockHandleUpdateWorkflowCanvas,
+ }),
+}))
+
+vi.mock('@/app/components/workflow/run', () => ({
+ default: ({
+ runDetailUrl,
+ tracingListUrl,
+ getResultCallback,
+ }: {
+ runDetailUrl: string
+ tracingListUrl: string
+ getResultCallback: (res: WorkflowRunDetailResponse) => void
+ }) => {
+ latestGetResultCallback = getResultCallback
+ return (
+
+ )
+ },
+}))
+
+vi.mock('@/app/components/workflow/utils', () => ({
+ formatWorkflowRunIdentifier: (finishedAt?: number) => mockFormatWorkflowRunIdentifier(finishedAt),
+}))
+
+const createRunDetail = (overrides: Partial = {}): WorkflowRunDetailResponse => ({
+ id: 'run-1',
+ version: '1',
+ graph: {
+ nodes: [],
+ edges: [],
+ },
+ inputs: '{}',
+ inputs_truncated: false,
+ status: 'succeeded',
+ outputs: '{}',
+ outputs_truncated: false,
+ total_steps: 1,
+ created_by_role: 'account',
+ created_at: 1,
+ finished_at: 2,
+ ...overrides,
+})
+
+describe('Record', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ latestGetResultCallback = undefined
+ })
+
+ it('renders the run title and passes run and trace URLs to the run panel', () => {
+ const getWorkflowRunAndTraceUrl = vi.fn((runId?: string) => ({
+ runUrl: `/runs/${runId}`,
+ traceUrl: `/traces/${runId}`,
+ }))
+
+ renderWorkflowComponent(, {
+ initialStoreState: {
+ historyWorkflowData: {
+ id: 'run-1',
+ status: 'succeeded',
+ finished_at: 1700000000000,
+ },
+ },
+ hooksStoreProps: {
+ getWorkflowRunAndTraceUrl,
+ },
+ })
+
+ expect(screen.getByText('Test Run (Finished)')).toBeInTheDocument()
+ expect(screen.getByTestId('run')).toHaveAttribute('data-run-detail-url', '/runs/run-1')
+ expect(screen.getByTestId('run')).toHaveAttribute('data-tracing-list-url', '/traces/run-1')
+ expect(getWorkflowRunAndTraceUrl).toHaveBeenCalledTimes(2)
+ expect(getWorkflowRunAndTraceUrl).toHaveBeenNthCalledWith(1, 'run-1')
+ expect(getWorkflowRunAndTraceUrl).toHaveBeenNthCalledWith(2, 'run-1')
+ expect(mockFormatWorkflowRunIdentifier).toHaveBeenCalledWith(1700000000000)
+ })
+
+ it('updates the workflow canvas with a fallback viewport when the response omits one', () => {
+ const nodes = [createNode({ id: 'node-1' })]
+ const edges = [createEdge({ id: 'edge-1' })]
+
+ renderWorkflowComponent(, {
+ initialStoreState: {
+ historyWorkflowData: {
+ id: 'run-1',
+ status: 'succeeded',
+ },
+ },
+ hooksStoreProps: {
+ getWorkflowRunAndTraceUrl: () => ({ runUrl: '/runs/run-1', traceUrl: '/traces/run-1' }),
+ },
+ })
+
+ expect(latestGetResultCallback).toBeDefined()
+
+ act(() => {
+ latestGetResultCallback?.(createRunDetail({
+ graph: {
+ nodes,
+ edges,
+ },
+ }))
+ })
+
+ expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
+ nodes,
+ edges,
+ viewport: { x: 0, y: 0, zoom: 1 },
+ })
+ })
+
+ it('uses the response viewport when one is available', () => {
+ const nodes = [createNode({ id: 'node-1' })]
+ const edges = [createEdge({ id: 'edge-1' })]
+ const viewport = { x: 12, y: 24, zoom: 0.75 }
+
+ renderWorkflowComponent(, {
+ initialStoreState: {
+ historyWorkflowData: {
+ id: 'run-1',
+ status: 'succeeded',
+ },
+ },
+ hooksStoreProps: {
+ getWorkflowRunAndTraceUrl: () => ({ runUrl: '/runs/run-1', traceUrl: '/traces/run-1' }),
+ },
+ })
+
+ act(() => {
+ latestGetResultCallback?.(createRunDetail({
+ graph: {
+ nodes,
+ edges,
+ viewport,
+ },
+ }))
+ })
+
+ expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
+ nodes,
+ edges,
+ viewport,
+ })
+ })
+})
diff --git a/web/app/components/workflow/run/__tests__/meta.spec.tsx b/web/app/components/workflow/run/__tests__/meta.spec.tsx
new file mode 100644
index 0000000000..2a1a4f4b1a
--- /dev/null
+++ b/web/app/components/workflow/run/__tests__/meta.spec.tsx
@@ -0,0 +1,68 @@
+import { render, screen } from '@testing-library/react'
+import Meta from '../meta'
+
+const mockFormatTime = vi.fn((value: number) => `formatted:${value}`)
+
+vi.mock('@/hooks/use-timestamp', () => ({
+ default: () => ({
+ formatTime: mockFormatTime,
+ }),
+}))
+
+describe('Meta', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('renders loading placeholders while the run is in progress', () => {
+ const { container } = render()
+
+ expect(container.querySelectorAll('.bg-text-quaternary')).toHaveLength(6)
+ expect(screen.queryByText('SUCCESS')).not.toBeInTheDocument()
+ expect(screen.queryByText('runLog.meta.steps')).toBeInTheDocument()
+ })
+
+ it.each([
+ ['succeeded', 'SUCCESS'],
+ ['partial-succeeded', 'PARTIAL SUCCESS'],
+ ['exception', 'EXCEPTION'],
+ ['failed', 'FAIL'],
+ ['stopped', 'STOP'],
+ ['paused', 'PENDING'],
+ ] as const)('renders the %s status label', (status, label) => {
+ render()
+
+ expect(screen.getByText(label)).toBeInTheDocument()
+ })
+
+ it('renders explicit metadata values and hides steps when requested', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('Alice')).toBeInTheDocument()
+ expect(screen.getByText('formatted:1700000000000')).toBeInTheDocument()
+ expect(screen.getByText('1.235s')).toBeInTheDocument()
+ expect(screen.getByText('42 Tokens')).toBeInTheDocument()
+ expect(screen.queryByText('Run Steps')).not.toBeInTheDocument()
+ expect(mockFormatTime).toHaveBeenCalledWith(1700000000000, expect.any(String))
+ })
+
+ it('falls back to default values when metadata is missing', () => {
+ render()
+
+ expect(screen.getByText('N/A')).toBeInTheDocument()
+ expect(screen.getAllByText('-')).toHaveLength(2)
+ expect(screen.getByText('0 Tokens')).toBeInTheDocument()
+ expect(screen.getByText('runLog.meta.steps').parentElement).toHaveTextContent('1')
+ expect(mockFormatTime).not.toHaveBeenCalled()
+ })
+})
diff --git a/web/app/components/workflow/run/__tests__/output-panel.spec.tsx b/web/app/components/workflow/run/__tests__/output-panel.spec.tsx
new file mode 100644
index 0000000000..34b13011ed
--- /dev/null
+++ b/web/app/components/workflow/run/__tests__/output-panel.spec.tsx
@@ -0,0 +1,137 @@
+import type { FileEntity } from '@/app/components/base/file-uploader/types'
+import type { FileResponse } from '@/types/workflow'
+import { render, screen } from '@testing-library/react'
+import { TransferMethod } from '@/types/app'
+import OutputPanel from '../output-panel'
+
+type FileOutput = FileResponse & { dify_model_identity: '__dify__file__' }
+
+vi.mock('@/app/components/base/chat/chat/loading-anim', () => ({
+ default: () => ,
+}))
+
+vi.mock('@/app/components/base/file-uploader', () => ({
+ FileList: ({ files }: { files: FileEntity[] }) => (
+ {files.map(file => file.name).join(', ')}
+ ),
+}))
+
+vi.mock('@/app/components/base/markdown', () => ({
+ Markdown: ({ content }: { content: string }) => {content}
,
+}))
+
+vi.mock('@/app/components/workflow/run/status-container', () => ({
+ default: ({ status, children }: { status: string, children?: React.ReactNode }) => (
+ {children}
+ ),
+}))
+
+vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
+ default: ({
+ language,
+ value,
+ height,
+ }: {
+ language: string
+ value: string
+ height?: number
+ }) => (
+
+ {value}
+
+ ),
+}))
+
+const createFileOutput = (overrides: Partial = {}): FileOutput => ({
+ dify_model_identity: '__dify__file__',
+ related_id: 'file-1',
+ extension: 'pdf',
+ filename: 'report.pdf',
+ size: 128,
+ mime_type: 'application/pdf',
+ transfer_method: TransferMethod.local_file,
+ type: 'document',
+ url: 'https://example.com/report.pdf',
+ upload_file_id: 'upload-1',
+ remote_url: '',
+ ...overrides,
+})
+
+describe('OutputPanel', () => {
+ it('renders the loading animation while the workflow is running', () => {
+ render()
+
+ expect(screen.getByTestId('loading-anim')).toBeInTheDocument()
+ })
+
+ it('renders the failed status container when there is an error', () => {
+ render()
+
+ expect(screen.getByTestId('status-container')).toHaveAttribute('data-status', 'failed')
+ expect(screen.getByText('Execution failed')).toBeInTheDocument()
+ })
+
+ it('renders the no-output placeholder when there are no outputs', () => {
+ render()
+
+ expect(screen.getByTestId('markdown')).toHaveTextContent('No Output')
+ })
+
+ it('renders a plain text output as markdown', () => {
+ render()
+
+ expect(screen.getByTestId('markdown')).toHaveTextContent('Hello Dify')
+ })
+
+ it('renders array text outputs as joined markdown content', () => {
+ render()
+
+ expect(screen.getByTestId('markdown')).toHaveTextContent(/Line 1\s+Line 2/)
+ })
+
+ it('renders a file list for a single file output', () => {
+ render()
+
+ expect(screen.getByTestId('file-list')).toHaveTextContent('report.pdf')
+ })
+
+ it('renders a file list for an array of file outputs', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByTestId('file-list')).toHaveTextContent('report.pdf, summary.md')
+ })
+
+ it('renders structured outputs inside the code editor when height is available', () => {
+ render()
+
+ expect(screen.getByTestId('code-editor')).toHaveAttribute('data-language', 'json')
+ expect(screen.getByTestId('code-editor')).toHaveAttribute('data-height', '92')
+ expect(screen.getByTestId('code-editor')).toHaveAttribute('data-value', `{
+ "answer": "hello",
+ "score": 1
+}`)
+ })
+
+ it('skips the code editor when structured outputs have no positive height', () => {
+ render()
+
+ expect(screen.queryByTestId('code-editor')).not.toBeInTheDocument()
+ })
+})
diff --git a/web/app/components/workflow/run/__tests__/result-text.spec.tsx b/web/app/components/workflow/run/__tests__/result-text.spec.tsx
new file mode 100644
index 0000000000..9b0827c2f0
--- /dev/null
+++ b/web/app/components/workflow/run/__tests__/result-text.spec.tsx
@@ -0,0 +1,88 @@
+import type { FileEntity } from '@/app/components/base/file-uploader/types'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { TransferMethod } from '@/types/app'
+import ResultText from '../result-text'
+
+vi.mock('@/app/components/base/chat/chat/loading-anim', () => ({
+ default: () => ,
+}))
+
+vi.mock('@/app/components/base/file-uploader', () => ({
+ FileList: ({ files }: { files: FileEntity[] }) => (
+ {files.map(file => file.name).join(', ')}
+ ),
+}))
+
+vi.mock('@/app/components/base/markdown', () => ({
+ Markdown: ({ content }: { content: string }) => {content}
,
+}))
+
+vi.mock('@/app/components/workflow/run/status-container', () => ({
+ default: ({ status, children }: { status: string, children?: React.ReactNode }) => (
+ {children}
+ ),
+}))
+
+describe('ResultText', () => {
+ it('renders the loading animation while waiting for a text result', () => {
+ render()
+
+ expect(screen.getByTestId('loading-anim')).toBeInTheDocument()
+ })
+
+ it('renders the error state when the run fails', () => {
+ render()
+
+ expect(screen.getByTestId('status-container')).toHaveAttribute('data-status', 'failed')
+ expect(screen.getByText('Run failed')).toBeInTheDocument()
+ })
+
+ it('renders the empty-state call to action and forwards clicks', () => {
+ const onClick = vi.fn()
+ render()
+
+ expect(screen.getByText('runLog.resultEmpty.title')).toBeInTheDocument()
+
+ fireEvent.click(screen.getByText('runLog.resultEmpty.link'))
+
+ expect(onClick).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not render the empty state for paused runs', () => {
+ render()
+
+ expect(screen.queryByText('runLog.resultEmpty.title')).not.toBeInTheDocument()
+ })
+
+ it('renders markdown content when text outputs are available', () => {
+ render()
+
+ expect(screen.getByTestId('markdown')).toHaveTextContent('hello workflow')
+ })
+
+ it('renders file groups when file outputs are available', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('attachments')).toBeInTheDocument()
+ expect(screen.getByTestId('file-list')).toHaveTextContent('report.pdf')
+ })
+})
diff --git a/web/app/components/workflow/run/__tests__/status.spec.tsx b/web/app/components/workflow/run/__tests__/status.spec.tsx
new file mode 100644
index 0000000000..25d3ceb278
--- /dev/null
+++ b/web/app/components/workflow/run/__tests__/status.spec.tsx
@@ -0,0 +1,131 @@
+import type { WorkflowPausedDetailsResponse } from '@/models/log'
+import { render, screen } from '@testing-library/react'
+import Status from '../status'
+
+const mockDocLink = vi.fn((path: string) => `https://docs.example.com${path}`)
+const mockUseWorkflowPausedDetails = vi.fn()
+
+vi.mock('@/context/i18n', () => ({
+ useDocLink: () => mockDocLink,
+}))
+
+vi.mock('@/service/use-log', () => ({
+ useWorkflowPausedDetails: (params: { workflowRunId: string, enabled?: boolean }) => mockUseWorkflowPausedDetails(params),
+}))
+
+const createPausedDetails = (overrides: Partial = {}): WorkflowPausedDetailsResponse => ({
+ paused_at: '2026-03-18T00:00:00Z',
+ paused_nodes: [],
+ ...overrides,
+})
+
+describe('Status', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockUseWorkflowPausedDetails.mockReturnValue({ data: undefined })
+ })
+
+ it('renders the running status and loading placeholders', () => {
+ render()
+
+ expect(screen.getByText('Running')).toBeInTheDocument()
+ expect(document.querySelectorAll('.bg-text-quaternary')).toHaveLength(2)
+ expect(mockUseWorkflowPausedDetails).toHaveBeenCalledWith({
+ workflowRunId: 'run-1',
+ enabled: false,
+ })
+ })
+
+ it('renders the listening label when the run is waiting for input', () => {
+ render()
+
+ expect(screen.getByText('Listening')).toBeInTheDocument()
+ })
+
+ it('renders succeeded metadata values', () => {
+ render()
+
+ expect(screen.getByText('SUCCESS')).toBeInTheDocument()
+ expect(screen.getByText('1.234s')).toBeInTheDocument()
+ expect(screen.getByText('8 Tokens')).toBeInTheDocument()
+ })
+
+ it('renders stopped fallbacks when time and tokens are missing', () => {
+ render()
+
+ expect(screen.getByText('STOP')).toBeInTheDocument()
+ expect(screen.getByText('-')).toBeInTheDocument()
+ expect(screen.getByText('0 Tokens')).toBeInTheDocument()
+ })
+
+ it('renders failed details and the partial-success exception tip', () => {
+ render()
+
+ expect(screen.getByText('FAIL')).toBeInTheDocument()
+ expect(screen.getByText('Something broke')).toBeInTheDocument()
+ expect(screen.getByText('workflow.nodes.common.errorHandle.partialSucceeded.tip:{"num":2}')).toBeInTheDocument()
+ })
+
+ it('renders the partial-succeeded warning summary', () => {
+ render()
+
+ expect(screen.getByText('PARTIAL SUCCESS')).toBeInTheDocument()
+ expect(screen.getByText('workflow.nodes.common.errorHandle.partialSucceeded.tip:{"num":3}')).toBeInTheDocument()
+ })
+
+ it('renders the exception learn-more link', () => {
+ render()
+
+ const learnMoreLink = screen.getByRole('link', { name: 'workflow.common.learnMore' })
+
+ expect(screen.getByText('EXCEPTION')).toBeInTheDocument()
+ expect(learnMoreLink).toHaveAttribute('href', 'https://docs.example.com/use-dify/debug/error-type')
+ expect(mockDocLink).toHaveBeenCalledWith('/use-dify/debug/error-type')
+ })
+
+ it('renders paused placeholders when pause details have not loaded yet', () => {
+ render()
+
+ expect(screen.getByText('PENDING')).toBeInTheDocument()
+ expect(screen.getByText('workflow.nodes.humanInput.log.reason')).toBeInTheDocument()
+ expect(document.querySelectorAll('.bg-text-quaternary')).toHaveLength(3)
+ expect(mockUseWorkflowPausedDetails).toHaveBeenCalledWith({
+ workflowRunId: 'run-3',
+ enabled: true,
+ })
+ })
+
+ it('renders paused human-input reasons and backstage URLs', () => {
+ mockUseWorkflowPausedDetails.mockReturnValue({
+ data: createPausedDetails({
+ paused_nodes: [
+ {
+ node_id: 'node-1',
+ node_title: 'Need review',
+ pause_type: {
+ type: 'human_input',
+ form_id: 'form-1',
+ backstage_input_url: 'https://example.com/a',
+ },
+ },
+ {
+ node_id: 'node-2',
+ node_title: 'Need review 2',
+ pause_type: {
+ type: 'human_input',
+ form_id: 'form-2',
+ backstage_input_url: 'https://example.com/b',
+ },
+ },
+ ],
+ }),
+ })
+
+ render()
+
+ expect(screen.getByText('workflow.nodes.humanInput.log.reasonContent')).toBeInTheDocument()
+ expect(screen.getByText('workflow.nodes.humanInput.log.backstageInputURL')).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'https://example.com/a' })).toHaveAttribute('href', 'https://example.com/a')
+ expect(screen.getByRole('link', { name: 'https://example.com/b' })).toHaveAttribute('href', 'https://example.com/b')
+ })
+})
diff --git a/web/app/components/workflow/workflow-preview/components/__tests__/error-handle-on-node.spec.tsx b/web/app/components/workflow/workflow-preview/components/__tests__/error-handle-on-node.spec.tsx
new file mode 100644
index 0000000000..b4e06676cd
--- /dev/null
+++ b/web/app/components/workflow/workflow-preview/components/__tests__/error-handle-on-node.spec.tsx
@@ -0,0 +1,84 @@
+import type { NodeProps } from 'reactflow'
+import type { CommonNodeType } from '@/app/components/workflow/types'
+import { render, screen, waitFor } from '@testing-library/react'
+import ReactFlow, { ReactFlowProvider } from 'reactflow'
+import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
+import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types'
+import ErrorHandleOnNode from '../error-handle-on-node'
+
+const createNodeData = (overrides: Partial = {}): CommonNodeType => ({
+ type: BlockEnum.Code,
+ title: 'Node',
+ desc: '',
+ ...overrides,
+})
+
+const ErrorNode = ({ id, data }: NodeProps) => (
+
+
+
+)
+
+const renderErrorNode = (data: CommonNodeType) => {
+ return render(
+
+
+
+
+
,
+ )
+}
+
+describe('ErrorHandleOnNode', () => {
+ // Empty and default-value states.
+ describe('Rendering', () => {
+ it('should render nothing when the node has no error strategy', () => {
+ const { container } = renderErrorNode(createNodeData())
+
+ expect(screen.queryByText('workflow.common.onFailure')).not.toBeInTheDocument()
+ expect(container.querySelector('.react-flow__handle')).not.toBeInTheDocument()
+ })
+
+ it('should render the default-value label', async () => {
+ renderErrorNode(createNodeData({ error_strategy: ErrorHandleTypeEnum.defaultValue }))
+
+ await waitFor(() => expect(screen.getByText('workflow.common.onFailure')).toBeInTheDocument())
+ expect(screen.getByText('workflow.common.onFailure')).toBeInTheDocument()
+ expect(screen.getByText('workflow.nodes.common.errorHandle.defaultValue.output')).toBeInTheDocument()
+ })
+ })
+
+ // Fail-branch behavior and warning styling.
+ describe('Effects', () => {
+ it('should render the fail-branch source handle', async () => {
+ const { container } = renderErrorNode(createNodeData({ error_strategy: ErrorHandleTypeEnum.failBranch }))
+
+ await waitFor(() => expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.title')).toBeInTheDocument())
+ expect(screen.getByText('workflow.nodes.common.errorHandle.failBranch.title')).toBeInTheDocument()
+ expect(container.querySelector('.react-flow__handle')).toHaveAttribute('data-handleid', ErrorHandleTypeEnum.failBranch)
+ })
+
+ it('should add warning styles when the node is in exception status', async () => {
+ const { container } = renderErrorNode(createNodeData({
+ error_strategy: ErrorHandleTypeEnum.defaultValue,
+ _runningStatus: NodeRunningStatus.Exception,
+ }))
+
+ await waitFor(() => expect(container.querySelector('.bg-state-warning-hover')).toBeInTheDocument())
+ expect(container.querySelector('.bg-state-warning-hover')).toHaveClass('border-components-badge-status-light-warning-halo')
+ expect(container.querySelector('.text-text-warning')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/workflow/workflow-preview/components/__tests__/node-handle.spec.tsx b/web/app/components/workflow/workflow-preview/components/__tests__/node-handle.spec.tsx
new file mode 100644
index 0000000000..a354ee9afb
--- /dev/null
+++ b/web/app/components/workflow/workflow-preview/components/__tests__/node-handle.spec.tsx
@@ -0,0 +1,130 @@
+import type { NodeProps } from 'reactflow'
+import type { CommonNodeType } from '@/app/components/workflow/types'
+import { render, waitFor } from '@testing-library/react'
+import ReactFlow, { ReactFlowProvider } from 'reactflow'
+import { BlockEnum } from '@/app/components/workflow/types'
+import { NodeSourceHandle, NodeTargetHandle } from '../node-handle'
+
+const createNodeData = (overrides: Partial = {}): CommonNodeType => ({
+ type: BlockEnum.Code,
+ title: 'Node',
+ desc: '',
+ ...overrides,
+})
+
+const TargetHandleNode = ({ id, data }: NodeProps) => (
+
+
+
+)
+
+const SourceHandleNode = ({ id, data }: NodeProps) => (
+
+
+
+)
+
+const renderFlowNode = (type: 'targetNode' | 'sourceNode', data: CommonNodeType) => {
+ return render(
+
+
+
+
+
,
+ )
+}
+
+describe('node-handle', () => {
+ // Target handle states and visibility rules.
+ describe('NodeTargetHandle', () => {
+ it('should hide the connection indicator when the target handle is not connected', async () => {
+ const { container } = renderFlowNode('targetNode', createNodeData())
+
+ await waitFor(() => expect(container.querySelector('.target-marker')).toBeInTheDocument())
+
+ const handle = container.querySelector('.target-marker')
+
+ expect(handle).toHaveAttribute('data-handleid', 'target-1')
+ expect(handle).toHaveClass('after:opacity-0')
+ })
+
+ it('should merge custom classes and hide start-like nodes completely', async () => {
+ const { container } = render(
+
+
+ ) => (
+
+
+
+ ),
+ }}
+ />
+
+
,
+ )
+
+ await waitFor(() => expect(container.querySelector('.custom-target')).toBeInTheDocument())
+
+ const handle = container.querySelector('.custom-target')
+
+ expect(handle).toHaveClass('opacity-0')
+ expect(handle).toHaveClass('custom-target')
+ })
+ })
+
+ // Source handle connection state.
+ describe('NodeSourceHandle', () => {
+ it('should keep the source indicator visible when the handle is connected', async () => {
+ const { container } = renderFlowNode('sourceNode', createNodeData({ _connectedSourceHandleIds: ['source-1'] }))
+
+ await waitFor(() => expect(container.querySelector('.source-marker')).toBeInTheDocument())
+
+ const handle = container.querySelector('.source-marker')
+
+ expect(handle).toHaveAttribute('data-handleid', 'source-1')
+ expect(handle).not.toHaveClass('after:opacity-0')
+ })
+ })
+})
diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json
index 6671296efa..a678b53eba 100644
--- a/web/eslint-suppressions.json
+++ b/web/eslint-suppressions.json
@@ -6602,11 +6602,6 @@
"count": 1
}
},
- "app/components/workflow/header/scroll-to-selected-node-button.tsx": {
- "tailwindcss/enforce-consistent-class-order": {
- "count": 1
- }
- },
"app/components/workflow/header/test-run-menu.tsx": {
"no-restricted-imports": {
"count": 1
From 93f95463539868ed74522d5d33cf767475858337 Mon Sep 17 00:00:00 2001
From: yyh <92089059+lyzno1@users.noreply.github.com>
Date: Wed, 18 Mar 2026 16:53:55 +0800
Subject: [PATCH 09/36] refactor(web): migrate core toast call sites to base ui
toast (#33643)
---
.../billing/cloud-plan-payment-flow.test.tsx | 28 +++---
.../billing/self-hosted-plan-flow.test.tsx | 58 ++++++-------
web/app/account/oauth/authorize/page.tsx | 12 +--
.../create-app-dialog/app-list/index.spec.tsx | 4 +-
.../app/create-app-dialog/app-list/index.tsx | 8 +-
web/app/components/base/toast/context.ts | 10 +++
web/app/components/base/toast/index.tsx | 9 ++
.../cloud-plan-item/__tests__/index.spec.tsx | 26 +++---
.../pricing/plans/cloud-plan-item/index.tsx | 21 +++--
.../__tests__/index.spec.tsx | 26 +++---
.../plans/self-hosted-plan-item/index.tsx | 17 ++--
.../detail/__tests__/new-segment.spec.tsx | 81 ++++++-----------
.../__tests__/new-child-segment.spec.tsx | 70 +++++++++------
.../detail/completed/new-child-segment.tsx | 58 ++++---------
.../datasets/documents/detail/new-segment.tsx | 65 ++++----------
.../connector/__tests__/index.spec.tsx | 18 ++--
.../connector/index.tsx | 7 +-
.../__tests__/index.spec.tsx | 10 +--
.../system-model-selector/index.tsx | 5 +-
.../__tests__/delete-confirm.spec.tsx | 12 +--
.../subscription-list/delete-confirm.tsx | 86 ++++++++++++-------
.../components/variable/output-var-list.tsx | 26 +++---
.../_base/components/variable/var-list.tsx | 22 ++---
.../panel/version-history-panel/index.tsx | 30 +++----
.../components/mail-and-password-auth.tsx | 18 ++--
web/context/provider-context-provider.tsx | 12 ++-
web/eslint-suppressions.json | 84 ------------------
web/service/fetch.spec.ts | 6 +-
web/service/fetch.ts | 4 +-
29 files changed, 353 insertions(+), 480 deletions(-)
diff --git a/web/__tests__/billing/cloud-plan-payment-flow.test.tsx b/web/__tests__/billing/cloud-plan-payment-flow.test.tsx
index bd3b6aa8d8..84653cd68c 100644
--- a/web/__tests__/billing/cloud-plan-payment-flow.test.tsx
+++ b/web/__tests__/billing/cloud-plan-payment-flow.test.tsx
@@ -11,6 +11,7 @@ import type { BasicPlan } from '@/app/components/billing/type'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
+import { toast, ToastHost } from '@/app/components/base/ui/toast'
import { ALL_PLANS } from '@/app/components/billing/config'
import { PlanRange } from '@/app/components/billing/pricing/plan-switcher/plan-range-switcher'
import CloudPlanItem from '@/app/components/billing/pricing/plans/cloud-plan-item'
@@ -21,7 +22,6 @@ let mockAppCtx: Record = {}
const mockFetchSubscriptionUrls = vi.fn()
const mockInvoices = vi.fn()
const mockOpenAsyncWindow = vi.fn()
-const mockToastNotify = vi.fn()
// ─── Context mocks ───────────────────────────────────────────────────────────
vi.mock('@/context/app-context', () => ({
@@ -49,10 +49,6 @@ vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => mockOpenAsyncWindow,
}))
-vi.mock('@/app/components/base/toast', () => ({
- default: { notify: (args: unknown) => mockToastNotify(args) },
-}))
-
// ─── Navigation mocks ───────────────────────────────────────────────────────
vi.mock('@/next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
@@ -82,12 +78,15 @@ const renderCloudPlanItem = ({
canPay = true,
}: RenderCloudPlanItemOptions = {}) => {
return render(
- ,
+ <>
+
+
+ >,
)
}
@@ -96,6 +95,7 @@ describe('Cloud Plan Payment Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
cleanup()
+ toast.close()
setupAppContext()
mockFetchSubscriptionUrls.mockResolvedValue({ url: 'https://pay.example.com/checkout' })
mockInvoices.mockResolvedValue({ url: 'https://billing.example.com/invoices' })
@@ -283,11 +283,7 @@ describe('Cloud Plan Payment Flow', () => {
await user.click(button)
await waitFor(() => {
- expect(mockToastNotify).toHaveBeenCalledWith(
- expect.objectContaining({
- type: 'error',
- }),
- )
+ expect(screen.getByText('billing.buyPermissionDeniedTip')).toBeInTheDocument()
})
// Should not proceed with payment
expect(mockFetchSubscriptionUrls).not.toHaveBeenCalled()
diff --git a/web/__tests__/billing/self-hosted-plan-flow.test.tsx b/web/__tests__/billing/self-hosted-plan-flow.test.tsx
index 810d36da8a..0802b760e1 100644
--- a/web/__tests__/billing/self-hosted-plan-flow.test.tsx
+++ b/web/__tests__/billing/self-hosted-plan-flow.test.tsx
@@ -10,12 +10,12 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
+import { toast, ToastHost } from '@/app/components/base/ui/toast'
import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '@/app/components/billing/config'
import SelfHostedPlanItem from '@/app/components/billing/pricing/plans/self-hosted-plan-item'
import { SelfHostedPlan } from '@/app/components/billing/type'
let mockAppCtx: Record = {}
-const mockToastNotify = vi.fn()
const originalLocation = window.location
let assignedHref = ''
@@ -40,10 +40,6 @@ vi.mock('@/app/components/base/icons/src/public/billing', () => ({
AwsMarketplaceDark: () => ,
}))
-vi.mock('@/app/components/base/toast', () => ({
- default: { notify: (args: unknown) => mockToastNotify(args) },
-}))
-
vi.mock('@/app/components/billing/pricing/plans/self-hosted-plan-item/list', () => ({
default: ({ plan }: { plan: string }) => (
Features
@@ -57,10 +53,20 @@ const setupAppContext = (overrides: Record = {}) => {
}
}
+const renderSelfHostedPlanItem = (plan: SelfHostedPlan) => {
+ return render(
+ <>
+
+
+ >,
+ )
+}
+
describe('Self-Hosted Plan Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
cleanup()
+ toast.close()
setupAppContext()
// Mock window.location with minimal getter/setter (Location props are non-enumerable)
@@ -85,14 +91,14 @@ describe('Self-Hosted Plan Flow', () => {
// ─── 1. Plan Rendering ──────────────────────────────────────────────────
describe('Plan rendering', () => {
it('should render community plan with name and description', () => {
- render()
+ renderSelfHostedPlanItem(SelfHostedPlan.community)
expect(screen.getByText(/plans\.community\.name/i)).toBeInTheDocument()
expect(screen.getByText(/plans\.community\.description/i)).toBeInTheDocument()
})
it('should render premium plan with cloud provider icons', () => {
- render()
+ renderSelfHostedPlanItem(SelfHostedPlan.premium)
expect(screen.getByText(/plans\.premium\.name/i)).toBeInTheDocument()
expect(screen.getByTestId('icon-azure')).toBeInTheDocument()
@@ -100,39 +106,39 @@ describe('Self-Hosted Plan Flow', () => {
})
it('should render enterprise plan without cloud provider icons', () => {
- render()
+ renderSelfHostedPlanItem(SelfHostedPlan.enterprise)
expect(screen.getByText(/plans\.enterprise\.name/i)).toBeInTheDocument()
expect(screen.queryByTestId('icon-azure')).not.toBeInTheDocument()
})
it('should not show price tip for community (free) plan', () => {
- render()
+ renderSelfHostedPlanItem(SelfHostedPlan.community)
expect(screen.queryByText(/plans\.community\.priceTip/i)).not.toBeInTheDocument()
})
it('should show price tip for premium plan', () => {
- render()
+ renderSelfHostedPlanItem(SelfHostedPlan.premium)
expect(screen.getByText(/plans\.premium\.priceTip/i)).toBeInTheDocument()
})
it('should render features list for each plan', () => {
- const { unmount: unmount1 } = render()
+ const { unmount: unmount1 } = renderSelfHostedPlanItem(SelfHostedPlan.community)
expect(screen.getByTestId('self-hosted-list-community')).toBeInTheDocument()
unmount1()
- const { unmount: unmount2 } = render()
+ const { unmount: unmount2 } = renderSelfHostedPlanItem(SelfHostedPlan.premium)
expect(screen.getByTestId('self-hosted-list-premium')).toBeInTheDocument()
unmount2()
- render()
+ renderSelfHostedPlanItem(SelfHostedPlan.enterprise)
expect(screen.getByTestId('self-hosted-list-enterprise')).toBeInTheDocument()
})
it('should show AWS marketplace icon for premium plan button', () => {
- render()
+ renderSelfHostedPlanItem(SelfHostedPlan.premium)
expect(screen.getByTestId('icon-aws-light')).toBeInTheDocument()
})
@@ -142,7 +148,7 @@ describe('Self-Hosted Plan Flow', () => {
describe('Navigation flow', () => {
it('should redirect to GitHub when clicking community plan button', async () => {
const user = userEvent.setup()
- render()
+ renderSelfHostedPlanItem(SelfHostedPlan.community)
const button = screen.getByRole('button')
await user.click(button)
@@ -152,7 +158,7 @@ describe('Self-Hosted Plan Flow', () => {
it('should redirect to AWS Marketplace when clicking premium plan button', async () => {
const user = userEvent.setup()
- render()
+ renderSelfHostedPlanItem(SelfHostedPlan.premium)
const button = screen.getByRole('button')
await user.click(button)
@@ -162,7 +168,7 @@ describe('Self-Hosted Plan Flow', () => {
it('should redirect to Typeform when clicking enterprise plan button', async () => {
const user = userEvent.setup()
- render()
+ renderSelfHostedPlanItem(SelfHostedPlan.enterprise)
const button = screen.getByRole('button')
await user.click(button)
@@ -176,15 +182,13 @@ describe('Self-Hosted Plan Flow', () => {
it('should show error toast when non-manager clicks community button', async () => {
setupAppContext({ isCurrentWorkspaceManager: false })
const user = userEvent.setup()
- render()
+ renderSelfHostedPlanItem(SelfHostedPlan.community)
const button = screen.getByRole('button')
await user.click(button)
await waitFor(() => {
- expect(mockToastNotify).toHaveBeenCalledWith(
- expect.objectContaining({ type: 'error' }),
- )
+ expect(screen.getByText('billing.buyPermissionDeniedTip')).toBeInTheDocument()
})
// Should NOT redirect
expect(assignedHref).toBe('')
@@ -193,15 +197,13 @@ describe('Self-Hosted Plan Flow', () => {
it('should show error toast when non-manager clicks premium button', async () => {
setupAppContext({ isCurrentWorkspaceManager: false })
const user = userEvent.setup()
- render()
+ renderSelfHostedPlanItem(SelfHostedPlan.premium)
const button = screen.getByRole('button')
await user.click(button)
await waitFor(() => {
- expect(mockToastNotify).toHaveBeenCalledWith(
- expect.objectContaining({ type: 'error' }),
- )
+ expect(screen.getByText('billing.buyPermissionDeniedTip')).toBeInTheDocument()
})
expect(assignedHref).toBe('')
})
@@ -209,15 +211,13 @@ describe('Self-Hosted Plan Flow', () => {
it('should show error toast when non-manager clicks enterprise button', async () => {
setupAppContext({ isCurrentWorkspaceManager: false })
const user = userEvent.setup()
- render()
+ renderSelfHostedPlanItem(SelfHostedPlan.enterprise)
const button = screen.getByRole('button')
await user.click(button)
await waitFor(() => {
- expect(mockToastNotify).toHaveBeenCalledWith(
- expect.objectContaining({ type: 'error' }),
- )
+ expect(screen.getByText('billing.buyPermissionDeniedTip')).toBeInTheDocument()
})
expect(assignedHref).toBe('')
})
diff --git a/web/app/account/oauth/authorize/page.tsx b/web/app/account/oauth/authorize/page.tsx
index 5ca920343e..30cfdd25d3 100644
--- a/web/app/account/oauth/authorize/page.tsx
+++ b/web/app/account/oauth/authorize/page.tsx
@@ -13,7 +13,7 @@ import { useTranslation } from 'react-i18next'
import { Avatar } from '@/app/components/base/avatar'
import Button from '@/app/components/base/button'
import Loading from '@/app/components/base/loading'
-import Toast from '@/app/components/base/toast'
+import { toast } from '@/app/components/base/ui/toast'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { setPostLoginRedirect } from '@/app/signin/utils/post-login-redirect'
import { useRouter, useSearchParams } from '@/next/navigation'
@@ -91,9 +91,9 @@ export default function OAuthAuthorize() {
globalThis.location.href = url.toString()
}
catch (err: any) {
- Toast.notify({
+ toast.add({
type: 'error',
- message: `${t('error.authorizeFailed', { ns: 'oauth' })}: ${err.message}`,
+ title: `${t('error.authorizeFailed', { ns: 'oauth' })}: ${err.message}`,
})
}
}
@@ -102,10 +102,10 @@ export default function OAuthAuthorize() {
const invalidParams = !client_id || !redirect_uri
if ((invalidParams || isError) && !hasNotifiedRef.current) {
hasNotifiedRef.current = true
- Toast.notify({
+ toast.add({
type: 'error',
- message: invalidParams ? t('error.invalidParams', { ns: 'oauth' }) : t('error.authAppInfoFetchFailed', { ns: 'oauth' }),
- duration: 0,
+ title: invalidParams ? t('error.invalidParams', { ns: 'oauth' }) : t('error.authAppInfoFetchFailed', { ns: 'oauth' }),
+ timeout: 0,
})
}
}, [client_id, redirect_uri, isError])
diff --git a/web/app/components/app/create-app-dialog/app-list/index.spec.tsx b/web/app/components/app/create-app-dialog/app-list/index.spec.tsx
index e2db3a94f7..a9b65a4ae9 100644
--- a/web/app/components/app/create-app-dialog/app-list/index.spec.tsx
+++ b/web/app/components/app/create-app-dialog/app-list/index.spec.tsx
@@ -39,8 +39,8 @@ vi.mock('../app-card', () => ({
vi.mock('@/app/components/explore/create-app-modal', () => ({
default: () => ,
}))
-vi.mock('@/app/components/base/toast', () => ({
- default: { notify: vi.fn() },
+vi.mock('@/app/components/base/ui/toast', () => ({
+ toast: { add: vi.fn() },
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
diff --git a/web/app/components/app/create-app-dialog/app-list/index.tsx b/web/app/components/app/create-app-dialog/app-list/index.tsx
index 5dea3e8aef..8b1876be04 100644
--- a/web/app/components/app/create-app-dialog/app-list/index.tsx
+++ b/web/app/components/app/create-app-dialog/app-list/index.tsx
@@ -12,7 +12,7 @@ import { trackEvent } from '@/app/components/base/amplitude'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
import Loading from '@/app/components/base/loading'
-import Toast from '@/app/components/base/toast'
+import { toast } from '@/app/components/base/ui/toast'
import CreateAppModal from '@/app/components/explore/create-app-modal'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
@@ -137,9 +137,9 @@ const Apps = ({
})
setIsShowCreateModal(false)
- Toast.notify({
+ toast.add({
type: 'success',
- message: t('newApp.appCreated', { ns: 'app' }),
+ title: t('newApp.appCreated', { ns: 'app' }),
})
if (onSuccess)
onSuccess()
@@ -149,7 +149,7 @@ const Apps = ({
getRedirection(isCurrentWorkspaceEditor, { id: app.app_id!, mode }, push)
}
catch {
- Toast.notify({ type: 'error', message: t('newApp.appCreateFailed', { ns: 'app' }) })
+ toast.add({ type: 'error', title: t('newApp.appCreateFailed', { ns: 'app' }) })
}
}
diff --git a/web/app/components/base/toast/context.ts b/web/app/components/base/toast/context.ts
index ddd8f91336..07b4e72602 100644
--- a/web/app/components/base/toast/context.ts
+++ b/web/app/components/base/toast/context.ts
@@ -1,8 +1,15 @@
'use client'
+/**
+ * @deprecated Use `@/app/components/base/ui/toast` instead.
+ * This module will be removed after migration is complete.
+ * See: https://github.com/langgenius/dify/issues/32811
+ */
+
import type { ReactNode } from 'react'
import { createContext, useContext } from 'use-context-selector'
+/** @deprecated Use `@/app/components/base/ui/toast` instead. See issue #32811. */
export type IToastProps = {
type?: 'success' | 'error' | 'warning' | 'info'
size?: 'md' | 'sm'
@@ -19,5 +26,8 @@ type IToastContext = {
close: () => void
}
+/** @deprecated Use `@/app/components/base/ui/toast` instead. See issue #32811. */
export const ToastContext = createContext({} as IToastContext)
+
+/** @deprecated Use `@/app/components/base/ui/toast` instead. See issue #32811. */
export const useToastContext = () => useContext(ToastContext)
diff --git a/web/app/components/base/toast/index.tsx b/web/app/components/base/toast/index.tsx
index 897b6039ba..0cb14f3f11 100644
--- a/web/app/components/base/toast/index.tsx
+++ b/web/app/components/base/toast/index.tsx
@@ -1,4 +1,11 @@
'use client'
+
+/**
+ * @deprecated Use `@/app/components/base/ui/toast` instead.
+ * This component will be removed after migration is complete.
+ * See: https://github.com/langgenius/dify/issues/32811
+ */
+
import type { ReactNode } from 'react'
import type { IToastProps } from './context'
import { noop } from 'es-toolkit/function'
@@ -12,6 +19,7 @@ import { ToastContext, useToastContext } from './context'
export type ToastHandle = {
clear?: VoidFunction
}
+
const Toast = ({
type = 'info',
size = 'md',
@@ -74,6 +82,7 @@ const Toast = ({
)
}
+/** @deprecated Use `@/app/components/base/ui/toast` instead. See issue #32811. */
export const ToastProvider = ({
children,
}: {
diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx
index 1c7283abeb..bd602df6c1 100644
--- a/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx
+++ b/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx
@@ -1,22 +1,16 @@
import type { Mock } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
+import { toast, ToastHost } from '@/app/components/base/ui/toast'
import { useAppContext } from '@/context/app-context'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { fetchSubscriptionUrls } from '@/service/billing'
import { consoleClient } from '@/service/client'
-import Toast from '../../../../../base/toast'
import { ALL_PLANS } from '../../../../config'
import { Plan } from '../../../../type'
import { PlanRange } from '../../../plan-switcher/plan-range-switcher'
import CloudPlanItem from '../index'
-vi.mock('../../../../../base/toast', () => ({
- default: {
- notify: vi.fn(),
- },
-}))
-
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
@@ -47,11 +41,19 @@ const mockUseAppContext = useAppContext as Mock
const mockUseAsyncWindowOpen = useAsyncWindowOpen as Mock
const mockBillingInvoices = consoleClient.billing.invoices as Mock
const mockFetchSubscriptionUrls = fetchSubscriptionUrls as Mock
-const mockToastNotify = Toast.notify as Mock
let assignedHref = ''
const originalLocation = window.location
+const renderWithToastHost = (ui: React.ReactNode) => {
+ return render(
+ <>
+
+ {ui}
+ >,
+ )
+}
+
beforeAll(() => {
Object.defineProperty(window, 'location', {
configurable: true,
@@ -68,6 +70,7 @@ beforeAll(() => {
beforeEach(() => {
vi.clearAllMocks()
+ toast.close()
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: true })
mockUseAsyncWindowOpen.mockReturnValue(vi.fn(async open => await open()))
mockBillingInvoices.mockResolvedValue({ url: 'https://billing.example' })
@@ -163,7 +166,7 @@ describe('CloudPlanItem', () => {
it('should show toast when non-manager tries to buy a plan', () => {
mockUseAppContext.mockReturnValue({ isCurrentWorkspaceManager: false })
- render(
+ renderWithToastHost(
{
)
fireEvent.click(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }))
- expect(mockToastNotify).toHaveBeenCalledWith(expect.objectContaining({
- type: 'error',
- message: 'billing.buyPermissionDeniedTip',
- }))
+ expect(screen.getByText('billing.buyPermissionDeniedTip')).toBeInTheDocument()
expect(mockBillingInvoices).not.toHaveBeenCalled()
})
diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx
index 0807381bcd..56856ccb77 100644
--- a/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx
+++ b/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx
@@ -4,11 +4,11 @@ import type { BasicPlan } from '../../../type'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
+import { toast } from '@/app/components/base/ui/toast'
import { useAppContext } from '@/context/app-context'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { fetchSubscriptionUrls } from '@/service/billing'
import { consoleClient } from '@/service/client'
-import Toast from '../../../../base/toast'
import { ALL_PLANS } from '../../../config'
import { Plan } from '../../../type'
import { Professional, Sandbox, Team } from '../../assets'
@@ -66,10 +66,9 @@ const CloudPlanItem: FC = ({
return
if (!isCurrentWorkspaceManager) {
- Toast.notify({
+ toast.add({
type: 'error',
- message: t('buyPermissionDeniedTip', { ns: 'billing' }),
- className: 'z-[1001]',
+ title: t('buyPermissionDeniedTip', { ns: 'billing' }),
})
return
}
@@ -83,7 +82,7 @@ const CloudPlanItem: FC = ({
throw new Error('Failed to open billing page')
}, {
onError: (err) => {
- Toast.notify({ type: 'error', message: err.message || String(err) })
+ toast.add({ type: 'error', title: err.message || String(err) })
},
})
return
@@ -111,34 +110,34 @@ const CloudPlanItem: FC = ({
{
isMostPopularPlan && (
-
+
{t('plansCommon.mostPopular', { ns: 'billing' })}
)
}
- {t(`${i18nPrefix}.description`, { ns: 'billing' })}
+ {t(`${i18nPrefix}.description`, { ns: 'billing' })}