-
+
{t('history.columns.time')}
- {t('history.columns.creator')}
- {t('history.columns.version')}
- {t('history.columns.status')}
+ {t('history.columns.creator')}
+ {t('history.columns.version')}
+ {t('history.columns.status')}
@@ -128,9 +128,9 @@ const HistoryTab = ({
setSelectedRunId(resourceType, resourceId, runId)
}}
>
- {formatCreatedAt(record.created_at)}
- {record.created_by}
- {record.version || '-'}
+ {formatCreatedAt(record.created_at)}
+ {record.created_by}
+ {record.version || '-'}
{record.result_file
?
@@ -181,7 +181,7 @@ const HistoryTab = ({
{!isInitialLoading && records.length === 0 && (
-
+
{t('history.empty')}
)}
diff --git a/web/app/components/evaluation/components/batch-test-panel/index.tsx b/web/app/components/evaluation/components/batch-test-panel/index.tsx
index 58e8efab5d..ce3ace9ef5 100644
--- a/web/app/components/evaluation/components/batch-test-panel/index.tsx
+++ b/web/app/components/evaluation/components/batch-test-panel/index.tsx
@@ -1,10 +1,10 @@
'use client'
import type { BatchTestTab, EvaluationResourceProps } from '../../types'
-import { cn } from '@langgenius/dify-ui/cn'
-import { useTranslation } from 'react-i18next'
import { Button } from '@langgenius/dify-ui/button'
+import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
+import { useTranslation } from 'react-i18next'
import { useSaveEvaluationConfigMutation } from '@/service/use-evaluation'
import { isEvaluationRunnable, useEvaluationResource, useEvaluationStore } from '../../store'
import { buildEvaluationConfigPayload } from '../../store-utils'
@@ -65,7 +65,7 @@ const BatchTestPanel = ({
{t('batch.title')}
-
{t('batch.description')}
+
{t('batch.description')}
-
{metric.label}
+
{metric.label}
{selectedNodeInfoList.length
? selectedNodeInfoList.map((nodeInfo) => {
- const nodeVisual = getNodeVisual(nodeInfo)
- const nodeToneClasses = getToneClasses(nodeVisual.tone)
+ const nodeVisual = getNodeVisual(nodeInfo)
+ const nodeToneClasses = getToneClasses(nodeVisual.tone)
- return (
-
-
-
-
-
{nodeInfo.title}
-
updateBuiltinMetric(
- resourceType,
- resourceId,
- metric.optionId,
- selectedNodeInfoList.filter(item => item.node_id !== nodeInfo.node_id),
- )}
+ return (
+
-
-
-
- )
- })
+
+
+
+ {nodeInfo.title}
+ updateBuiltinMetric(
+ resourceType,
+ resourceId,
+ metric.optionId,
+ selectedNodeInfoList.filter(item => item.node_id !== nodeInfo.node_id),
+ )}
+ >
+
+
+
+ )
+ })
: (
- {t('metrics.nodesAll')}
- )}
+ {t('metrics.nodesAll')}
+ )}
{shouldShowAddNode && (
@@ -144,7 +144,7 @@ const BuiltinMetricCard = ({
- {nodeInfo.title}
+ {nodeInfo.title}
)
diff --git a/web/app/components/evaluation/components/metric-section/custom-metric-card.tsx b/web/app/components/evaluation/components/metric-section/custom-metric-card.tsx
index d25fd32b94..93d2ab66bd 100644
--- a/web/app/components/evaluation/components/metric-section/custom-metric-card.tsx
+++ b/web/app/components/evaluation/components/metric-section/custom-metric-card.tsx
@@ -1,10 +1,10 @@
'use client'
import type { EvaluationMetric, EvaluationResourceProps } from '../../types'
+import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
-import { Button } from '@langgenius/dify-ui/button'
import { isCustomMetricConfigured, useEvaluationStore } from '../../store'
import CustomMetricEditorCard from '../custom-metric-editor'
import { getToneClasses } from '../metric-selector/utils'
@@ -30,7 +30,7 @@ const CustomMetricCard = ({
-
{metric.label}
+
{metric.label}
diff --git a/web/app/components/evaluation/components/pipeline/pipeline-batch-actions.tsx b/web/app/components/evaluation/components/pipeline/pipeline-batch-actions.tsx
index 867c67a587..cc1c415bde 100644
--- a/web/app/components/evaluation/components/pipeline/pipeline-batch-actions.tsx
+++ b/web/app/components/evaluation/components/pipeline/pipeline-batch-actions.tsx
@@ -2,8 +2,8 @@
import type { EvaluationResourceProps } from '../../types'
import type { InputField } from '../batch-test-panel/input-fields/input-fields-utils'
-import { useTranslation } from 'react-i18next'
import { Button } from '@langgenius/dify-ui/button'
+import { useTranslation } from 'react-i18next'
import { getEvaluationMockConfig } from '../../mock'
import { isEvaluationRunnable, useEvaluationResource } from '../../store'
import UploadRunPopover from '../batch-test-panel/input-fields/upload-run-popover'
diff --git a/web/app/components/explore/app-card/__tests__/index.spec.tsx b/web/app/components/explore/app-card/__tests__/index.spec.tsx
index c42f6f1d32..43b8e4fde2 100644
--- a/web/app/components/explore/app-card/__tests__/index.spec.tsx
+++ b/web/app/components/explore/app-card/__tests__/index.spec.tsx
@@ -1,7 +1,8 @@
import type { AppCardProps } from '../index'
import type { App } from '@/models/explore'
-import { fireEvent, render, screen } from '@testing-library/react'
+import { fireEvent, screen } from '@testing-library/react'
import * as React from 'react'
+import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
import { trackEvent } from '@/app/components/base/amplitude'
import { AppModeEnum } from '@/types/app'
import AppCard from '../index'
diff --git a/web/app/components/explore/app-card/index.tsx b/web/app/components/explore/app-card/index.tsx
index 869e2a6a18..afa46f6ed4 100644
--- a/web/app/components/explore/app-card/index.tsx
+++ b/web/app/components/explore/app-card/index.tsx
@@ -5,10 +5,11 @@ import { PlusIcon } from '@heroicons/react/20/solid'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiInformation2Line } from '@remixicon/react'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import AppIcon from '@/app/components/base/app-icon'
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { AppModeEnum } from '@/types/app'
import { AppTypeIcon } from '../../app/type-selector'
@@ -29,7 +30,7 @@ const AppCard = ({
}: AppCardProps) => {
const { t } = useTranslation()
const { app: appBasicInfo } = app
- const { systemFeatures } = useGlobalPublicStore()
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isTrialApp = app.can_trial && systemFeatures.enable_trial_app
const handleTryApp = () => {
trackEvent('preview_template', {
diff --git a/web/app/components/explore/app-list/__tests__/index.spec.tsx b/web/app/components/explore/app-list/__tests__/index.spec.tsx
index 3cdc60ed46..02081af1aa 100644
--- a/web/app/components/explore/app-list/__tests__/index.spec.tsx
+++ b/web/app/components/explore/app-list/__tests__/index.spec.tsx
@@ -1,9 +1,10 @@
+import type { ReactNode } from 'react'
import type { Mock } from 'vitest'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import type { App } from '@/models/explore'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
+import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
import { useAppContext } from '@/context/app-context'
-import { useGlobalPublicStore } from '@/context/global-public-context'
import { fetchAppDetail } from '@/service/explore'
import { useMembers } from '@/service/use-common'
import { renderWithNuqs } from '@/test/nuqs-testing'
@@ -134,12 +135,28 @@ const mockMemberRole = (hasEditPermission: boolean) => {
})
}
-const renderAppList = (hasEditPermission = false, onSuccess?: () => void, searchParams?: Record
) => {
+type RenderOptions = {
+ enableExploreBanner?: boolean
+}
+
+const renderAppList = (
+ hasEditPermission = false,
+ onSuccess?: () => void,
+ searchParams?: Record,
+ options: RenderOptions = {},
+) => {
mockMemberRole(hasEditPermission)
- return renderWithNuqs(
- ,
+ const { wrapper: SystemFeaturesWrapper, queryClient } = createSystemFeaturesWrapper({
+ systemFeatures: { enable_explore_banner: options.enableExploreBanner ?? false },
+ })
+ const Wrapped = ({ children }: { children: ReactNode }) => (
+ {children}
+ )
+ const rendered = renderWithNuqs(
+ ,
{ searchParams },
)
+ return { ...rendered, queryClient }
}
describe('AppList', () => {
@@ -438,18 +455,12 @@ describe('AppList', () => {
describe('Banner', () => {
it('should render banner when enable_explore_banner is true', () => {
- useGlobalPublicStore.setState({
- systemFeatures: {
- ...useGlobalPublicStore.getState().systemFeatures,
- enable_explore_banner: true,
- },
- })
mockExploreData = {
categories: ['Writing'],
allList: [createApp()],
}
- renderAppList()
+ renderAppList(false, undefined, undefined, { enableExploreBanner: true })
expect(screen.getByTestId('explore-banner')).toBeInTheDocument()
})
diff --git a/web/app/components/explore/app-list/index.tsx b/web/app/components/explore/app-list/index.tsx
index d57283b999..0cb0e30d2e 100644
--- a/web/app/components/explore/app-list/index.tsx
+++ b/web/app/components/explore/app-list/index.tsx
@@ -5,6 +5,7 @@ import type { App } from '@/models/explore'
import type { TryAppSelection } from '@/types/try-app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { useDebounceFn } from 'ahooks'
import { useQueryState } from 'nuqs'
import * as React from 'react'
@@ -18,12 +19,12 @@ import Banner from '@/app/components/explore/banner/banner'
import Category from '@/app/components/explore/category'
import CreateAppModal from '@/app/components/explore/create-app-modal'
import { useAppContext } from '@/context/app-context'
-import { useGlobalPublicStore } from '@/context/global-public-context'
import { useImportDSL } from '@/hooks/use-import-dsl'
import {
DSLImportMode,
} from '@/models/app'
import { fetchAppDetail } from '@/service/explore'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useMembers } from '@/service/use-common'
import { useExploreAppList } from '@/service/use-explore'
import { trackCreateApp } from '@/utils/create-app-tracking'
@@ -39,7 +40,7 @@ const Apps = ({
}: AppsProps) => {
const { t } = useTranslation()
const { userProfile } = useAppContext()
- const { systemFeatures } = useGlobalPublicStore()
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: membersData } = useMembers()
const allCategoriesEn = t('apps.allCategories', { ns: 'explore', lng: 'en' })
const userAccount = membersData?.accounts?.find(account => account.id === userProfile.id)
diff --git a/web/app/components/explore/try-app/__tests__/index.spec.tsx b/web/app/components/explore/try-app/__tests__/index.spec.tsx
index be6c6ea8e2..a84989dea9 100644
--- a/web/app/components/explore/try-app/__tests__/index.spec.tsx
+++ b/web/app/components/explore/try-app/__tests__/index.spec.tsx
@@ -1,6 +1,7 @@
import type { TryAppInfo } from '@/service/try-app'
-import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
import TryApp from '../index'
import { TypeEnum } from '../tab'
diff --git a/web/app/components/explore/try-app/index.tsx b/web/app/components/explore/try-app/index.tsx
index 209bde9cb6..8bb27d9086 100644
--- a/web/app/components/explore/try-app/index.tsx
+++ b/web/app/components/explore/try-app/index.tsx
@@ -3,13 +3,14 @@
import type { FC } from 'react'
import type { App as AppType } from '@/models/explore'
import { Button } from '@langgenius/dify-ui/button'
+import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useState } from 'react'
import AppUnavailable from '@/app/components/base/app-unavailable'
import Loading from '@/app/components/base/loading'
import Modal from '@/app/components/base/modal/index'
import { IS_CLOUD_EDITION } from '@/config'
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useGetTryAppInfo } from '@/service/use-try-app'
import App from './app'
import AppInfo from './app-info'
@@ -31,7 +32,7 @@ const TryApp: FC = ({
onClose,
onCreate,
}) => {
- const { systemFeatures } = useGlobalPublicStore()
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isTrialApp = !!(app && app.can_trial && systemFeatures.enable_trial_app)
const canUseTryTab = IS_CLOUD_EDITION && (app ? isTrialApp : true)
const [type, setType] = useState(() => (canUseTryTab ? TypeEnum.TRY : TypeEnum.DETAIL))
diff --git a/web/app/components/header/__tests__/index.spec.tsx b/web/app/components/header/__tests__/index.spec.tsx
index 16e0854339..ba284829f3 100644
--- a/web/app/components/header/__tests__/index.spec.tsx
+++ b/web/app/components/header/__tests__/index.spec.tsx
@@ -1,5 +1,7 @@
-import { fireEvent, render, screen } from '@testing-library/react'
+import type { ReactElement } from 'react'
+import { fireEvent, screen } from '@testing-library/react'
import { vi } from 'vitest'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import Header from '../index'
function createMockComponent(testId: string) {
@@ -93,21 +95,16 @@ vi.mock('@/context/modal-context', () => ({
}),
}))
-vi.mock('@/context/global-public-context', () => {
- type SystemFeatures = { branding: { enabled: boolean, application_title: string | null, workspace_logo: string | null } }
- return {
- useGlobalPublicStore: (selector: (s: { systemFeatures: SystemFeatures }) => SystemFeatures) =>
- selector({
- systemFeatures: {
- branding: {
- enabled: mockBrandingEnabled,
- application_title: mockBrandingTitle,
- workspace_logo: mockBrandingLogo,
- },
- },
- }),
- }
-})
+const renderHeader = (ui: ReactElement = ) =>
+ renderWithSystemFeatures(ui, {
+ systemFeatures: {
+ branding: {
+ enabled: mockBrandingEnabled,
+ application_title: mockBrandingTitle ?? '',
+ workspace_logo: mockBrandingLogo ?? '',
+ },
+ },
+ })
describe('Header', () => {
beforeEach(() => {
@@ -123,7 +120,7 @@ describe('Header', () => {
})
it('should render header with main nav components', () => {
- render()
+ renderHeader()
expect(screen.getByRole('img', { name: /dify logo/i })).toBeInTheDocument()
expect(screen.getByTestId('workplace-selector')).toBeInTheDocument()
@@ -133,7 +130,7 @@ describe('Header', () => {
it('should show license nav when billing disabled, plan badge when enabled', () => {
mockEnableBilling = false
- const { rerender } = render()
+ const { rerender } = renderHeader()
expect(screen.getByTestId('license-nav')).toBeInTheDocument()
expect(screen.queryByTestId('plan-badge')).not.toBeInTheDocument()
@@ -145,7 +142,7 @@ describe('Header', () => {
it('should hide explore nav when user is dataset operator', () => {
mockIsDatasetOperator = true
- render()
+ renderHeader()
expect(screen.queryByTestId('explore-nav')).not.toBeInTheDocument()
expect(screen.getByTestId('dataset-nav')).toBeInTheDocument()
@@ -154,7 +151,7 @@ describe('Header', () => {
it('should call pricing modal for free plan, settings modal for paid plan', () => {
mockEnableBilling = true
mockPlanType = 'sandbox'
- const { rerender } = render()
+ const { rerender } = renderHeader()
fireEvent.click(screen.getByTestId('plan-badge'))
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
@@ -167,7 +164,7 @@ describe('Header', () => {
it('should render mobile layout without env nav', () => {
mockMedia = 'mobile'
- render()
+ renderHeader()
expect(screen.getByRole('img', { name: /dify logo/i })).toBeInTheDocument()
expect(screen.queryByTestId('env-nav')).not.toBeInTheDocument()
@@ -178,7 +175,7 @@ describe('Header', () => {
mockBrandingTitle = 'Acme Workspace'
mockBrandingLogo = '/logo.png'
- render()
+ renderHeader()
expect(screen.getByText('Acme Workspace')).toBeInTheDocument()
expect(screen.getByRole('img', { name: /logo/i })).toBeInTheDocument()
@@ -190,7 +187,7 @@ describe('Header', () => {
mockBrandingTitle = 'Custom Title'
mockBrandingLogo = null
- render()
+ renderHeader()
expect(screen.getByText('Custom Title')).toBeInTheDocument()
expect(screen.getByRole('img', { name: /dify logo/i })).toBeInTheDocument()
@@ -201,7 +198,7 @@ describe('Header', () => {
mockBrandingTitle = null
mockBrandingLogo = null
- render()
+ renderHeader()
expect(screen.getByText('Dify')).toBeInTheDocument()
})
@@ -210,7 +207,7 @@ describe('Header', () => {
mockIsWorkspaceEditor = true
mockIsDatasetOperator = false
- render()
+ renderHeader()
expect(screen.getByTestId('dataset-nav')).toBeInTheDocument()
expect(screen.getByTestId('explore-nav')).toBeInTheDocument()
@@ -221,7 +218,7 @@ describe('Header', () => {
mockIsWorkspaceEditor = false
mockIsDatasetOperator = false
- render()
+ renderHeader()
expect(screen.queryByTestId('dataset-nav')).not.toBeInTheDocument()
})
@@ -230,7 +227,7 @@ describe('Header', () => {
mockMedia = 'mobile'
mockIsDatasetOperator = true
- render()
+ renderHeader()
expect(screen.queryByTestId('explore-nav')).not.toBeInTheDocument()
expect(screen.queryByTestId('app-nav')).not.toBeInTheDocument()
@@ -243,7 +240,7 @@ describe('Header', () => {
mockEnableBilling = true
mockPlanType = 'sandbox'
- render()
+ renderHeader()
expect(screen.getByTestId('plan-badge')).toBeInTheDocument()
expect(screen.queryByTestId('license-nav')).not.toBeInTheDocument()
diff --git a/web/app/components/header/account-about/__tests__/index.spec.tsx b/web/app/components/header/account-about/__tests__/index.spec.tsx
index f4be46389e..694a632039 100644
--- a/web/app/components/header/account-about/__tests__/index.spec.tsx
+++ b/web/app/components/header/account-about/__tests__/index.spec.tsx
@@ -1,22 +1,16 @@
import type { LangGeniusVersionResponse } from '@/models/common'
-import type { SystemFeatures } from '@/types/feature'
-import { fireEvent, render, screen } from '@testing-library/react'
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { fireEvent, screen } from '@testing-library/react'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import AccountAbout from '../index'
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: vi.fn(),
-}))
-
let mockIsCEEdition = false
-vi.mock('@/config', () => ({
- get IS_CE_EDITION() { return mockIsCEEdition },
-}))
-
-type GlobalPublicStore = {
- systemFeatures: SystemFeatures
- setSystemFeatures: (systemFeatures: SystemFeatures) => void
-}
+vi.mock('@/config', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ get IS_CE_EDITION() { return mockIsCEEdition },
+ }
+})
describe('AccountAbout', () => {
const mockVersionInfo: LangGeniusVersionResponse = {
@@ -34,31 +28,23 @@ describe('AccountAbout', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCEEdition = false
- vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
- systemFeatures: { branding: { enabled: false } },
- } as unknown as GlobalPublicStore))
})
describe('Rendering', () => {
it('should render correctly with version information', () => {
- // Act
- render( )
+ renderWithSystemFeatures( , {
+ systemFeatures: { branding: { enabled: false } },
+ })
- // Assert
expect(screen.getByText(/^Version/)).toBeInTheDocument()
expect(screen.getAllByText(/0.6.0/).length).toBeGreaterThan(0)
})
it('should render branding logo if enabled', () => {
- // Arrange
- vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
+ renderWithSystemFeatures( , {
systemFeatures: { branding: { enabled: true, workspace_logo: 'custom-logo.png' } },
- } as unknown as GlobalPublicStore))
+ })
- // Act
- render( )
-
- // Assert
const img = screen.getByAltText('logo')
expect(img).toBeInTheDocument()
expect(img).toHaveAttribute('src', 'custom-logo.png')
@@ -67,21 +53,16 @@ describe('AccountAbout', () => {
describe('Version Logic', () => {
it('should show "Latest Available" when current version equals latest', () => {
- // Act
- render( )
+ renderWithSystemFeatures( )
- // Assert
expect(screen.getByText(/about.latestAvailable/)).toBeInTheDocument()
})
it('should show "Now Available" when current version is behind', () => {
- // Arrange
const behindVersionInfo = { ...mockVersionInfo, latest_version: '0.7.0' }
- // Act
- render( )
+ renderWithSystemFeatures( )
- // Assert
expect(screen.getByText(/about.nowAvailable/)).toBeInTheDocument()
expect(screen.getByText(/about.updateNow/)).toBeInTheDocument()
})
@@ -89,33 +70,26 @@ describe('AccountAbout', () => {
describe('Community Edition', () => {
it('should render correctly in Community Edition', () => {
- // Arrange
mockIsCEEdition = true
- // Act
- render( )
+ renderWithSystemFeatures( )
- // Assert
expect(screen.getByText(/Open Source License/)).toBeInTheDocument()
})
it('should hide update button in Community Edition when behind version', () => {
- // Arrange
mockIsCEEdition = true
const behindVersionInfo = { ...mockVersionInfo, latest_version: '0.7.0' }
- // Act
- render( )
+ renderWithSystemFeatures( )
- // Assert
expect(screen.queryByText(/about.updateNow/)).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onCancel when close button is clicked', () => {
- // Act
- render( )
+ renderWithSystemFeatures( )
// Modal uses Headless UI Dialog which renders into a portal, so we need to use document
const closeButton = document.querySelector('div.absolute.cursor-pointer')
diff --git a/web/app/components/header/account-about/index.tsx b/web/app/components/header/account-about/index.tsx
index 9375497566..e2535144f9 100644
--- a/web/app/components/header/account-about/index.tsx
+++ b/web/app/components/header/account-about/index.tsx
@@ -2,15 +2,16 @@
import type { LangGeniusVersionResponse } from '@/models/common'
import { Button } from '@langgenius/dify-ui/button'
import { RiCloseLine } from '@remixicon/react'
+import { useSuspenseQuery } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { useTranslation } from 'react-i18next'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import Modal from '@/app/components/base/modal'
import { IS_CE_EDITION } from '@/config'
-import { useGlobalPublicStore } from '@/context/global-public-context'
-
import Link from '@/next/link'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
+
type IAccountSettingProps = {
langGeniusVersionInfo: LangGeniusVersionResponse
onCancel: () => void
@@ -22,7 +23,7 @@ export default function AccountAbout({
}: IAccountSettingProps) {
const { t } = useTranslation()
const isLatest = langGeniusVersionInfo.current_version === langGeniusVersionInfo.latest_version
- const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
return (
= T extends Array
+ ? Array
+ : T extends object
+ ? { [K in keyof T]?: DeepPartial }
+ : T
+
vi.mock('../../account-setting', () => ({
default: () => AccountSetting
,
}))
@@ -37,10 +43,6 @@ vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: vi.fn(),
-}))
-
vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
}))
@@ -79,15 +81,19 @@ const { mockConfig, mockEnv } = vi.hoisted(() => ({
},
},
}))
-vi.mock('@/config', () => ({
- get IS_CLOUD_EDITION() { return mockConfig.IS_CLOUD_EDITION },
- get AMPLITUDE_API_KEY() { return mockConfig.AMPLITUDE_API_KEY },
- get isAmplitudeEnabled() { return mockConfig.IS_CLOUD_EDITION && !!mockConfig.AMPLITUDE_API_KEY },
- get ZENDESK_WIDGET_KEY() { return mockConfig.ZENDESK_WIDGET_KEY },
- get SUPPORT_EMAIL_ADDRESS() { return mockConfig.SUPPORT_EMAIL_ADDRESS },
- IS_DEV: false,
- IS_CE_EDITION: false,
-}))
+vi.mock('@/config', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ get IS_CLOUD_EDITION() { return mockConfig.IS_CLOUD_EDITION },
+ get AMPLITUDE_API_KEY() { return mockConfig.AMPLITUDE_API_KEY },
+ get isAmplitudeEnabled() { return mockConfig.IS_CLOUD_EDITION && !!mockConfig.AMPLITUDE_API_KEY },
+ get ZENDESK_WIDGET_KEY() { return mockConfig.ZENDESK_WIDGET_KEY },
+ get SUPPORT_EMAIL_ADDRESS() { return mockConfig.SUPPORT_EMAIL_ADDRESS },
+ IS_DEV: false,
+ IS_CE_EDITION: false,
+ }
+})
vi.mock('@/env', () => mockEnv)
const baseAppContextValue: AppContextValue = {
@@ -136,20 +142,13 @@ describe('AccountDropdown', () => {
const mockLogout = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
- const renderWithRouter = (ui: React.ReactElement) => {
- const queryClient = new QueryClient({
- defaultOptions: {
- queries: {
- retry: false,
- },
- },
+ const renderWithRouter = (
+ ui: React.ReactElement,
+ options: { systemFeatures?: DeepPartial } = {},
+ ) => {
+ return renderWithSystemFeatures(ui, {
+ systemFeatures: options.systemFeatures ?? { branding: { enabled: false } },
})
-
- return render(
-
- {ui}
- ,
- )
}
beforeEach(() => {
@@ -159,10 +158,6 @@ describe('AccountDropdown', () => {
mockEnv.env.NEXT_PUBLIC_SITE_ABOUT = 'show'
vi.mocked(useAppContext).mockReturnValue(baseAppContextValue)
- vi.mocked(useGlobalPublicStore).mockImplementation((selector?: unknown) => {
- const fullState = { systemFeatures: { branding: { enabled: false } }, setSystemFeatures: vi.fn() }
- return typeof selector === 'function' ? (selector as (state: typeof fullState) => unknown)(fullState) : fullState
- })
vi.mocked(useProviderContext).mockReturnValue({
isEducationAccount: false,
plan: { type: Plan.sandbox },
@@ -316,14 +311,10 @@ describe('AccountDropdown', () => {
describe('Branding and Environment', () => {
it('should hide sections when branding is enabled', () => {
- // Arrange
- vi.mocked(useGlobalPublicStore).mockImplementation((selector?: unknown) => {
- const fullState = { systemFeatures: { branding: { enabled: true } }, setSystemFeatures: vi.fn() }
- return typeof selector === 'function' ? (selector as (state: typeof fullState) => unknown)(fullState) : fullState
- })
-
// Act
- renderWithRouter( )
+ renderWithRouter( , {
+ systemFeatures: { branding: { enabled: true } },
+ })
fireEvent.click(screen.getByRole('button'))
// Assert
diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx
index 27a601a4ab..b62eb42482 100644
--- a/web/app/components/header/account-dropdown/index.tsx
+++ b/web/app/components/header/account-dropdown/index.tsx
@@ -4,6 +4,7 @@ import type { MouseEventHandler, ReactNode } from 'react'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { resetUser } from '@/app/components/base/amplitude/utils'
@@ -12,13 +13,13 @@ import ThemeSwitcher from '@/app/components/base/theme-switcher'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { IS_CLOUD_EDITION } from '@/config'
import { useAppContext } from '@/context/app-context'
-import { useGlobalPublicStore } from '@/context/global-public-context'
import { useDocLink } from '@/context/i18n'
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 { systemFeaturesQueryOptions } from '@/service/system-features'
import { useLogout } from '@/service/use-common'
import AccountAbout from '../account-about'
import GithubStar from '../github-star'
@@ -110,7 +111,7 @@ export default function AppSelector() {
const router = useRouter()
const [aboutVisible, setAboutVisible] = useState(false)
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
- const { systemFeatures } = useGlobalPublicStore()
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { t } = useTranslation()
const docLink = useDocLink()
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 d37b0bbcf2..4bf9d1c515 100644
--- a/web/app/components/header/account-setting/__tests__/index.spec.tsx
+++ b/web/app/components/header/account-setting/__tests__/index.spec.tsx
@@ -1,8 +1,8 @@
import type { AccountSettingTab } from '../constants'
import type { AppContextValue } from '@/context/app-context'
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { fireEvent, render, screen } from '@testing-library/react'
+import { fireEvent, screen } from '@testing-library/react'
import { useState } from 'react'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { useAppContext } from '@/context/app-context'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
@@ -47,36 +47,6 @@ vi.mock('@/hooks/use-breakpoints', () => ({
default: vi.fn(),
}))
-vi.mock('@/context/global-public-context', async (importOriginal) => {
- const actual = await importOriginal()
- const systemFeatures = {
- ...actual.useGlobalPublicStore.getState().systemFeatures,
- webapp_auth: {
- ...actual.useGlobalPublicStore.getState().systemFeatures.webapp_auth,
- enabled: true,
- },
- branding: {
- ...actual.useGlobalPublicStore.getState().systemFeatures.branding,
- enabled: false,
- },
- enable_marketplace: true,
- enable_collaboration_mode: false,
- }
-
- return {
- ...actual,
- useGlobalPublicStore: (selector: (state: Record) => unknown) => selector({
- systemFeatures,
- }),
- useSystemFeaturesQuery: () => ({
- data: systemFeatures,
- isPending: false,
- isLoading: false,
- isFetching: false,
- }),
- }
-})
-
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useDefaultModel: vi.fn(() => ({ data: null, isLoading: false })),
useUpdateDefaultModel: vi.fn(() => ({ trigger: vi.fn() })),
@@ -176,11 +146,14 @@ describe('AccountSetting', () => {
)
}
- return render(
-
-
- ,
- )
+ return renderWithSystemFeatures( , {
+ systemFeatures: {
+ webapp_auth: { enabled: true },
+ branding: { enabled: false },
+ enable_marketplace: true,
+ enable_collaboration_mode: false,
+ },
+ })
}
beforeEach(() => {
diff --git a/web/app/components/header/account-setting/data-source-page-new/__tests__/index.spec.tsx b/web/app/components/header/account-setting/data-source-page-new/__tests__/index.spec.tsx
index 9baea5c722..b88c234394 100644
--- a/web/app/components/header/account-setting/data-source-page-new/__tests__/index.spec.tsx
+++ b/web/app/components/header/account-setting/data-source-page-new/__tests__/index.spec.tsx
@@ -1,12 +1,11 @@
import type { UseQueryResult } from '@tanstack/react-query'
import type { DataSourceAuth } from '../types'
-import { render, screen } from '@testing-library/react'
+import { screen } from '@testing-library/react'
import { useTheme } from 'next-themes'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { usePluginAuthAction } from '@/app/components/plugins/plugin-auth'
-import { useGlobalPublicStore } from '@/context/global-public-context'
import { useRenderI18nObject } from '@/hooks/use-i18n'
import { useGetDataSourceListAuth, useGetDataSourceOAuthUrl } from '@/service/use-datasource'
-import { defaultSystemFeatures } from '@/types/feature'
import { useDataSourceAuthUpdate, useMarketplaceAllPlugins } from '../hooks'
import DataSourcePage from '../index'
@@ -24,10 +23,6 @@ vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: vi.fn(),
}))
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: vi.fn(),
-}))
-
vi.mock('@/service/use-datasource', () => ({
useGetDataSourceListAuth: vi.fn(),
useGetDataSourceOAuthUrl: vi.fn(),
@@ -96,18 +91,14 @@ describe('DataSourcePage Component', () => {
describe('Initial View Rendering', () => {
it('should render an empty view when no data is available and marketplace is disabled', () => {
// Arrange
- /* eslint-disable-next-line ts/no-explicit-any */
- vi.mocked(useGlobalPublicStore).mockImplementation((selector: any) =>
- selector({
- systemFeatures: { ...defaultSystemFeatures, enable_marketplace: false },
- }),
- )
vi.mocked(useGetDataSourceListAuth).mockReturnValue({
data: undefined,
} as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>)
// Act
- render( )
+ renderWithSystemFeatures( , {
+ systemFeatures: { enable_marketplace: false },
+ })
// Assert
expect(screen.queryByText('Dify Source')).not.toBeInTheDocument()
@@ -118,18 +109,14 @@ describe('DataSourcePage Component', () => {
describe('Data Source List Rendering', () => {
it('should render Card components for each data source returned from the API', () => {
// Arrange
- /* eslint-disable-next-line ts/no-explicit-any */
- vi.mocked(useGlobalPublicStore).mockImplementation((selector: any) =>
- selector({
- systemFeatures: { ...defaultSystemFeatures, enable_marketplace: false },
- }),
- )
vi.mocked(useGetDataSourceListAuth).mockReturnValue({
data: { result: mockProviders },
} as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>)
// Act
- render( )
+ renderWithSystemFeatures( , {
+ systemFeatures: { enable_marketplace: false },
+ })
// Assert
expect(screen.getByText('Dify Source')).toBeInTheDocument()
@@ -140,18 +127,14 @@ describe('DataSourcePage Component', () => {
describe('Marketplace Integration', () => {
it('should render the InstallFromMarketplace component when enable_marketplace feature is enabled', () => {
// Arrange
- /* eslint-disable-next-line ts/no-explicit-any */
- vi.mocked(useGlobalPublicStore).mockImplementation((selector: any) =>
- selector({
- systemFeatures: { ...defaultSystemFeatures, enable_marketplace: true },
- }),
- )
vi.mocked(useGetDataSourceListAuth).mockReturnValue({
data: { result: mockProviders },
} as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>)
// Act
- render( )
+ renderWithSystemFeatures( , {
+ systemFeatures: { enable_marketplace: true },
+ })
// Assert
expect(screen.getByText('common.modelProvider.installDataSourceProvider')).toBeInTheDocument()
@@ -160,18 +143,14 @@ describe('DataSourcePage Component', () => {
it('should pass an empty array to InstallFromMarketplace if data result is missing but marketplace is enabled', () => {
// Arrange
- /* eslint-disable-next-line ts/no-explicit-any */
- vi.mocked(useGlobalPublicStore).mockImplementation((selector: any) =>
- selector({
- systemFeatures: { ...defaultSystemFeatures, enable_marketplace: true },
- }),
- )
vi.mocked(useGetDataSourceListAuth).mockReturnValue({
data: undefined,
} as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>)
// Act
- render( )
+ renderWithSystemFeatures( , {
+ systemFeatures: { enable_marketplace: true },
+ })
// Assert
expect(screen.getByText('common.modelProvider.installDataSourceProvider')).toBeInTheDocument()
@@ -179,38 +158,30 @@ describe('DataSourcePage Component', () => {
it('should handle the case where data exists but result is an empty array', () => {
// Arrange
- /* eslint-disable-next-line ts/no-explicit-any */
- vi.mocked(useGlobalPublicStore).mockImplementation((selector: any) =>
- selector({
- systemFeatures: { ...defaultSystemFeatures, enable_marketplace: true },
- }),
- )
vi.mocked(useGetDataSourceListAuth).mockReturnValue({
data: { result: [] },
} as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>)
// Act
- render( )
+ renderWithSystemFeatures( , {
+ systemFeatures: { enable_marketplace: true },
+ })
// Assert
expect(screen.queryByText('Dify Source')).not.toBeInTheDocument()
expect(screen.getByText('common.modelProvider.installDataSourceProvider')).toBeInTheDocument()
})
- it('should handle the case where systemFeatures is missing (edge case for coverage)', () => {
+ it('should handle the case where enable_marketplace is false (edge case for coverage)', () => {
// Arrange
- /* eslint-disable-next-line ts/no-explicit-any */
- vi.mocked(useGlobalPublicStore).mockImplementation((selector: any) =>
- selector({
- systemFeatures: {},
- }),
- )
vi.mocked(useGetDataSourceListAuth).mockReturnValue({
data: { result: [] },
} as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>)
// Act
- render( )
+ renderWithSystemFeatures( , {
+ systemFeatures: { enable_marketplace: false },
+ })
// Assert
expect(screen.queryByText('common.modelProvider.installDataSourceProvider')).not.toBeInTheDocument()
diff --git a/web/app/components/header/account-setting/data-source-page-new/index.tsx b/web/app/components/header/account-setting/data-source-page-new/index.tsx
index e7884cc2db..a12adfacbd 100644
--- a/web/app/components/header/account-setting/data-source-page-new/index.tsx
+++ b/web/app/components/header/account-setting/data-source-page-new/index.tsx
@@ -1,11 +1,15 @@
+import { useSuspenseQuery } from '@tanstack/react-query'
import { memo } from 'react'
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useGetDataSourceListAuth } from '@/service/use-datasource'
import Card from './card'
import InstallFromMarketplace from './install-from-marketplace'
const DataSourcePage = () => {
- const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
+ const { data: enable_marketplace } = useSuspenseQuery({
+ ...systemFeaturesQueryOptions(),
+ select: s => s.enable_marketplace,
+ })
const { data } = useGetDataSourceListAuth()
return (
diff --git a/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx
index b50073b1f2..c09f32ea83 100644
--- a/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx
+++ b/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx
@@ -1,23 +1,26 @@
import type { AppContextValue } from '@/context/app-context'
import type { ICurrentWorkspace, Member } from '@/models/common'
-import { render, screen } from '@testing-library/react'
+import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { Plan } from '@/app/components/billing/type'
import { useAppContext } from '@/context/app-context'
-import { useGlobalPublicStore } from '@/context/global-public-context'
import { useProviderContext } from '@/context/provider-context'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { useMembers } from '@/service/use-common'
import MembersPage from '../index'
vi.mock('@/context/app-context')
-vi.mock('@/context/global-public-context')
vi.mock('@/context/provider-context')
vi.mock('@/hooks/use-format-time-from-now')
vi.mock('@/service/use-common')
+const renderMembersPage = () => renderWithSystemFeatures( , {
+ systemFeatures: { is_email_setup: true },
+})
+
vi.mock('../edit-workspace-modal', () => ({
default: ({ onCancel }: { onCancel: () => void }) => (
@@ -112,10 +115,6 @@ describe('MembersPage', () => {
refetch: mockRefetch,
} as unknown as ReturnType
)
- vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
- systemFeatures: { is_email_setup: true },
- } as unknown as Parameters[0]))
-
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
enableBilling: false,
isAllowTransferWorkspace: true,
@@ -127,7 +126,7 @@ describe('MembersPage', () => {
})
it('should render workspace and member information', () => {
- render( )
+ renderMembersPage()
expect(screen.getByText('Test Workspace'))!.toBeInTheDocument()
expect(screen.getByText('Owner User'))!.toBeInTheDocument()
@@ -137,7 +136,7 @@ describe('MembersPage', () => {
it('should open and close invite modal', async () => {
const user = userEvent.setup()
- render( )
+ renderMembersPage()
await user.click(screen.getByRole('button', { name: /invite/i }))
expect(screen.getByText('Invite Modal'))!.toBeInTheDocument()
@@ -149,7 +148,7 @@ describe('MembersPage', () => {
it('should open invited modal after invite results are sent', async () => {
const user = userEvent.setup()
- render( )
+ renderMembersPage()
await user.click(screen.getByRole('button', { name: /invite/i }))
await user.click(screen.getByRole('button', { name: 'Send Invite Results' }))
@@ -164,7 +163,7 @@ describe('MembersPage', () => {
it('should open transfer ownership modal when transfer action is used', async () => {
const user = userEvent.setup()
- render( )
+ renderMembersPage()
await user.click(screen.getByRole('button', { name: /transfer ownership/i }))
expect(screen.getByText('Transfer Ownership Modal'))!.toBeInTheDocument()
@@ -176,7 +175,7 @@ describe('MembersPage', () => {
isAllowTransferWorkspace: false,
}))
- render( )
+ renderMembersPage()
expect(screen.getByText('common.members.owner'))!.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /transfer ownership/i })).not.toBeInTheDocument()
@@ -190,7 +189,7 @@ describe('MembersPage', () => {
isCurrentWorkspaceManager: false,
} as unknown as AppContextValue)
- render( )
+ renderMembersPage()
expect(screen.queryByRole('button', { name: /invite/i })).not.toBeInTheDocument()
expect(screen.queryByText('Transfer ownership')).not.toBeInTheDocument()
@@ -199,7 +198,7 @@ describe('MembersPage', () => {
it('should open and close edit workspace modal', async () => {
const user = userEvent.setup()
- render( )
+ renderMembersPage()
await user.click(screen.getByTestId('edit-workspace-pencil'))
expect(screen.getByText('Edit Workspace Modal'))!.toBeInTheDocument()
@@ -211,7 +210,7 @@ describe('MembersPage', () => {
it('should close transfer ownership modal when close is clicked', async () => {
const user = userEvent.setup()
- render( )
+ renderMembersPage()
await user.click(screen.getByRole('button', { name: /transfer ownership/i }))
expect(screen.getByText('Transfer Ownership Modal'))!.toBeInTheDocument()
@@ -230,7 +229,7 @@ describe('MembersPage', () => {
refetch: mockRefetch,
} as unknown as ReturnType)
- render( )
+ renderMembersPage()
expect(screen.getByText(/members\.pending/i))!.toBeInTheDocument()
expect(screen.getByText(/members\.you/i))!.toBeInTheDocument() // Current user is owner@example.com
@@ -245,7 +244,7 @@ describe('MembersPage', () => {
} as unknown as ReturnType['plan'],
}))
- render( )
+ renderMembersPage()
expect(screen.getByText(/plansCommon\.member/i))!.toBeInTheDocument()
expect(screen.getByText('2'))!.toBeInTheDocument() // accounts.length
@@ -262,7 +261,7 @@ describe('MembersPage', () => {
} as unknown as ReturnType['plan'],
}))
- render( )
+ renderMembersPage()
expect(screen.getByText(/plansCommon\.unlimited/i))!.toBeInTheDocument()
})
@@ -276,7 +275,7 @@ describe('MembersPage', () => {
} as unknown as ReturnType['plan'],
}))
- render( )
+ renderMembersPage()
// Plan.team is an unlimited member plan → isNotUnlimitedMemberPlan=false → non-billing layout
// Plan.team is an unlimited member plan → isNotUnlimitedMemberPlan=false → non-billing layout
@@ -291,7 +290,7 @@ describe('MembersPage', () => {
isCurrentWorkspaceManager: true,
} as unknown as AppContextValue)
- render( )
+ renderMembersPage()
expect(screen.getByRole('button', { name: /invite/i }))!.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /transfer ownership/i })).not.toBeInTheDocument()
@@ -308,7 +307,7 @@ describe('MembersPage', () => {
refetch: mockRefetch,
} as unknown as ReturnType)
- render( )
+ renderMembersPage()
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(1700000000000)
})
@@ -326,7 +325,7 @@ describe('MembersPage', () => {
} as unknown as ReturnType['plan'],
}))
- render( )
+ renderMembersPage()
expect(screen.getByText(/plansCommon\.member/i))!.toBeInTheDocument()
expect(screen.getByText('1'))!.toBeInTheDocument()
@@ -338,7 +337,7 @@ describe('MembersPage', () => {
refetch: mockRefetch,
} as unknown as ReturnType)
- render( )
+ renderMembersPage()
expect(screen.getByText(/plansCommon\.memberAfter/i))!.toBeInTheDocument()
expect(screen.getByText('1'))!.toBeInTheDocument()
@@ -356,7 +355,7 @@ describe('MembersPage', () => {
refetch: mockRefetch,
} as unknown as ReturnType)
- render( )
+ renderMembersPage()
expect(screen.getByText('common.members.normal'))!.toBeInTheDocument()
})
@@ -370,7 +369,7 @@ describe('MembersPage', () => {
} as unknown as ReturnType['plan'],
}))
- render( )
+ renderMembersPage()
expect(screen.getByText('Upgrade Button'))!.toBeInTheDocument()
})
diff --git a/web/app/components/header/account-setting/members-page/__tests__/invite-button.spec.tsx b/web/app/components/header/account-setting/members-page/__tests__/invite-button.spec.tsx
index c64e465279..43fcce16f7 100644
--- a/web/app/components/header/account-setting/members-page/__tests__/invite-button.spec.tsx
+++ b/web/app/components/header/account-setting/members-page/__tests__/invite-button.spec.tsx
@@ -1,35 +1,34 @@
import type { AppContextValue } from '@/context/app-context'
import type { ICurrentWorkspace } from '@/models/common'
-import { render, screen } from '@testing-library/react'
+import { screen } from '@testing-library/react'
import { vi } from 'vitest'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { useAppContext } from '@/context/app-context'
-import { useGlobalPublicStore } from '@/context/global-public-context'
import { useWorkspacePermissions } from '@/service/use-workspace'
import InviteButton from '../invite-button'
vi.mock('@/context/app-context')
-vi.mock('@/context/global-public-context')
vi.mock('@/service/use-workspace')
describe('InviteButton', () => {
- const setupMocks = ({
- brandingEnabled,
+ const setupPermissions = ({
isFetching,
allowInvite,
}: {
- brandingEnabled: boolean
isFetching: boolean
allowInvite?: boolean
}) => {
- vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
- systemFeatures: { branding: { enabled: brandingEnabled } },
- } as unknown as Parameters[0]))
vi.mocked(useWorkspacePermissions).mockReturnValue({
data: allowInvite === undefined ? null : { allow_member_invite: allowInvite },
isFetching,
} as unknown as ReturnType)
}
+ const renderInviteButton = (brandingEnabled: boolean) =>
+ renderWithSystemFeatures( , {
+ systemFeatures: { branding: { enabled: brandingEnabled } },
+ })
+
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useAppContext).mockReturnValue({
@@ -38,33 +37,33 @@ describe('InviteButton', () => {
})
it('should show invite button when branding is disabled', () => {
- setupMocks({ brandingEnabled: false, isFetching: false })
+ setupPermissions({ isFetching: false })
- render( )
+ renderInviteButton(false)
expect(screen.getByRole('button', { name: /members\.invite/i })).toBeInTheDocument()
})
it('should show loading status while permissions are loading', () => {
- setupMocks({ brandingEnabled: true, isFetching: true })
+ setupPermissions({ isFetching: true })
- render( )
+ renderInviteButton(true)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should hide invite button when permission is denied', () => {
- setupMocks({ brandingEnabled: true, isFetching: false, allowInvite: false })
+ setupPermissions({ isFetching: false, allowInvite: false })
- render( )
+ renderInviteButton(true)
expect(screen.queryByRole('button', { name: /members\.invite/i })).not.toBeInTheDocument()
})
it('should show invite button when permission is granted', () => {
- setupMocks({ brandingEnabled: true, isFetching: false, allowInvite: true })
+ setupPermissions({ isFetching: false, allowInvite: true })
- render( )
+ renderInviteButton(true)
expect(screen.getByRole('button', { name: /members\.invite/i })).toBeInTheDocument()
})
diff --git a/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx
index e2248a44c3..206a7c0148 100644
--- a/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx
+++ b/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx
@@ -54,7 +54,7 @@ describe('EditWorkspaceModal', () => {
expect(await screen.findByDisplayValue('Test Workspace')).toBeInTheDocument()
})
- it('should render on the base/ui overlay layer', async () => {
+ it('should render on the dify-ui overlay layer', async () => {
renderModal()
expect(await screen.findByRole('dialog')).toHaveClass('z-1002')
diff --git a/web/app/components/header/account-setting/members-page/index.tsx b/web/app/components/header/account-setting/members-page/index.tsx
index 408de64cb8..37913b6065 100644
--- a/web/app/components/header/account-setting/members-page/index.tsx
+++ b/web/app/components/header/account-setting/members-page/index.tsx
@@ -2,17 +2,18 @@
import type { InvitationResult } from '@/models/common'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { NUM_INFINITE } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import { useAppContext } from '@/context/app-context'
-import { useGlobalPublicStore } from '@/context/global-public-context'
import { useLocale } from '@/context/i18n'
import { useProviderContext } from '@/context/provider-context'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { LanguagesSupported } from '@/i18n-config/language'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useMembers } from '@/service/use-common'
import EditWorkspaceModal from './edit-workspace-modal'
import InviteButton from './invite-button'
@@ -35,7 +36,7 @@ const MembersPage = () => {
const { userProfile, currentWorkspace, isCurrentWorkspaceOwner, isCurrentWorkspaceManager } = useAppContext()
const { data, refetch } = useMembers()
- const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { formatTimeFromNow } = useFormatTimeFromNow()
const [inviteModalVisible, setInviteModalVisible] = useState(false)
const [invitationResults, setInvitationResults] = useState([])
diff --git a/web/app/components/header/account-setting/members-page/invite-button.tsx b/web/app/components/header/account-setting/members-page/invite-button.tsx
index c703924112..a9a8b88dcf 100644
--- a/web/app/components/header/account-setting/members-page/invite-button.tsx
+++ b/web/app/components/header/account-setting/members-page/invite-button.tsx
@@ -1,9 +1,10 @@
import { Button } from '@langgenius/dify-ui/button'
import { RiUserAddLine } from '@remixicon/react'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useWorkspacePermissions } from '@/service/use-workspace'
type InviteButtonProps = {
@@ -14,7 +15,7 @@ type InviteButtonProps = {
const InviteButton = (props: InviteButtonProps) => {
const { t } = useTranslation()
const { currentWorkspace } = useAppContext()
- const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: workspacePermissions, isFetching: isFetchingWorkspacePermissions } = useWorkspacePermissions(currentWorkspace!.id, systemFeatures.branding.enabled)
if (systemFeatures.branding.enabled) {
if (isFetchingWorkspacePermissions) {
diff --git a/web/app/components/header/account-setting/members-page/operation/__tests__/transfer-ownership.spec.tsx b/web/app/components/header/account-setting/members-page/operation/__tests__/transfer-ownership.spec.tsx
index a38e66a2f8..f7b8f1dd5a 100644
--- a/web/app/components/header/account-setting/members-page/operation/__tests__/transfer-ownership.spec.tsx
+++ b/web/app/components/header/account-setting/members-page/operation/__tests__/transfer-ownership.spec.tsx
@@ -1,36 +1,38 @@
import type { AppContextValue } from '@/context/app-context'
import type { ICurrentWorkspace } from '@/models/common'
-import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { useAppContext } from '@/context/app-context'
-import { useGlobalPublicStore } from '@/context/global-public-context'
import { useWorkspacePermissions } from '@/service/use-workspace'
import TransferOwnership from '../transfer-ownership'
vi.mock('@/context/app-context')
-vi.mock('@/context/global-public-context')
vi.mock('@/service/use-workspace')
describe('TransferOwnership', () => {
- const setupMocks = ({
- brandingEnabled,
+ const setupPermissions = ({
isFetching,
allowOwnerTransfer,
}: {
- brandingEnabled: boolean
isFetching: boolean
allowOwnerTransfer?: boolean
}) => {
- vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
- systemFeatures: { branding: { enabled: brandingEnabled } },
- } as unknown as Parameters[0]))
vi.mocked(useWorkspacePermissions).mockReturnValue({
data: allowOwnerTransfer === undefined ? null : { allow_owner_transfer: allowOwnerTransfer },
isFetching,
} as unknown as ReturnType)
}
+ const renderTransferOwnership = (
+ brandingEnabled: boolean,
+ onOperate: () => void = vi.fn(),
+ ) =>
+ renderWithSystemFeatures( , {
+ systemFeatures: { branding: { enabled: brandingEnabled } },
+ })
+
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useAppContext).mockReturnValue({
@@ -39,17 +41,17 @@ describe('TransferOwnership', () => {
})
it('should show loading status while permissions are loading', () => {
- setupMocks({ brandingEnabled: true, isFetching: true })
+ setupPermissions({ isFetching: true })
- render( )
+ renderTransferOwnership(true)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should show owner text without transfer menu when transfer is forbidden', () => {
- setupMocks({ brandingEnabled: true, isFetching: false, allowOwnerTransfer: false })
+ setupPermissions({ isFetching: false, allowOwnerTransfer: false })
- render( )
+ renderTransferOwnership(true)
expect(screen.getByText(/members\.owner/i)).toBeInTheDocument()
expect(screen.queryByText(/members\.transferOwnership/i)).toBeNull()
@@ -59,9 +61,9 @@ describe('TransferOwnership', () => {
const user = userEvent.setup()
const onOperate = vi.fn()
- setupMocks({ brandingEnabled: true, isFetching: false, allowOwnerTransfer: true })
+ setupPermissions({ isFetching: false, allowOwnerTransfer: true })
- render( )
+ renderTransferOwnership(true, onOperate)
await user.click(screen.getByRole('button', { name: /members\.owner/i }))
const transferOptionText = await screen.findByText(/members\.transferOwnership/i)
@@ -78,9 +80,9 @@ describe('TransferOwnership', () => {
it('should allow transfer menu when branding is disabled', async () => {
const user = userEvent.setup()
- setupMocks({ brandingEnabled: false, isFetching: false })
+ setupPermissions({ isFetching: false })
- render( )
+ renderTransferOwnership(false)
await user.click(screen.getByRole('button', { name: /members\.owner/i }))
diff --git a/web/app/components/header/account-setting/members-page/operation/transfer-ownership.tsx b/web/app/components/header/account-setting/members-page/operation/transfer-ownership.tsx
index b485aed704..97a4a5b2f2 100644
--- a/web/app/components/header/account-setting/members-page/operation/transfer-ownership.tsx
+++ b/web/app/components/header/account-setting/members-page/operation/transfer-ownership.tsx
@@ -4,11 +4,12 @@ import { cn } from '@langgenius/dify-ui/cn'
import {
RiArrowDownSLine,
} from '@remixicon/react'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { Fragment } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useWorkspacePermissions } from '@/service/use-workspace'
type Props = {
@@ -18,7 +19,7 @@ type Props = {
const TransferOwnership = ({ onOperate }: Props) => {
const { t } = useTranslation()
const { currentWorkspace } = useAppContext()
- const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: workspacePermissions, isFetching: isFetchingWorkspacePermissions } = useWorkspacePermissions(currentWorkspace!.id, systemFeatures.branding.enabled)
if (systemFeatures.branding.enabled) {
if (isFetchingWorkspacePermissions) {
diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/index.non-cloud.spec.tsx b/web/app/components/header/account-setting/model-provider-page/__tests__/index.non-cloud.spec.tsx
index 0fbed45fa6..8f9366635d 100644
--- a/web/app/components/header/account-setting/model-provider-page/__tests__/index.non-cloud.spec.tsx
+++ b/web/app/components/header/account-setting/model-provider-page/__tests__/index.non-cloud.spec.tsx
@@ -1,4 +1,5 @@
-import { render, screen } from '@testing-library/react'
+import { screen } from '@testing-library/react'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import {
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
@@ -15,17 +16,13 @@ const mockQuotaConfig = {
is_valid: true,
}
-vi.mock('@/config', () => ({
- IS_CLOUD_EDITION: false,
-}))
-
-vi.mock('@/context/global-public-context', () => ({
- useSystemFeaturesQuery: () => ({
- data: {
- enable_marketplace: false,
- },
- }),
-}))
+vi.mock('@/config', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ IS_CLOUD_EDITION: false,
+ }
+})
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
@@ -62,26 +59,41 @@ vi.mock('../install-from-marketplace', () => ({
default: () =>
,
}))
-vi.mock('@tanstack/react-query', async (importOriginal) => {
- const actual = await importOriginal()
+vi.mock('@/service/client', async (importOriginal) => {
+ const actual = await importOriginal()
+ const originalPlugins = actual.consoleQuery.plugins as unknown as Record
return {
...actual,
- useQuery: () => ({ data: undefined }),
+ consoleQuery: new Proxy(actual.consoleQuery, {
+ get(target, prop) {
+ if (prop === 'plugins') {
+ return {
+ ...originalPlugins,
+ checkInstalled: {
+ queryOptions: () => ({
+ queryKey: ['plugins', 'checkInstalled'],
+ queryFn: () => new Promise(() => {}),
+ }),
+ },
+ latestVersions: {
+ queryOptions: () => ({
+ queryKey: ['plugins', 'latestVersions'],
+ queryFn: () => new Promise(() => {}),
+ }),
+ },
+ }
+ }
+ return Reflect.get(target, prop)
+ },
+ }),
}
})
-vi.mock('@/service/client', () => ({
- consoleQuery: {
- plugins: {
- checkInstalled: { queryOptions: () => ({}) },
- latestVersions: { queryOptions: () => ({}) },
- },
- },
-}))
-
describe('ModelProviderPage non-cloud branch', () => {
it('should skip the quota panel when cloud edition is disabled', () => {
- render( )
+ renderWithSystemFeatures( , {
+ systemFeatures: { enable_marketplace: false },
+ })
expect(screen.getByTestId('system-model-selector')).toBeInTheDocument()
expect(screen.queryByTestId('quota-panel')).not.toBeInTheDocument()
diff --git a/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx
index 8d6c59bbf2..6bf818a544 100644
--- a/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx
+++ b/web/app/components/header/account-setting/model-provider-page/__tests__/index.spec.tsx
@@ -1,5 +1,6 @@
-import { act, render, screen } from '@testing-library/react'
+import { act, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import {
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
@@ -7,8 +8,6 @@ import {
} from '../declarations'
import ModelProviderPage from '../index'
-let mockEnableMarketplace = true
-
const mockQuotaConfig = {
quota_type: CurrentSystemQuotaTypeEnum.free,
quota_unit: QuotaUnitEnum.times,
@@ -18,13 +17,14 @@ const mockQuotaConfig = {
is_valid: true,
}
-vi.mock('@/context/global-public-context', () => ({
- useSystemFeaturesQuery: () => ({
- data: {
- enable_marketplace: mockEnableMarketplace,
- },
- }),
-}))
+const renderModelProviderPage = (
+ props: { searchText?: string, enableMarketplace?: boolean } = {},
+) => {
+ const { searchText = '', enableMarketplace = true } = props
+ return renderWithSystemFeatures( , {
+ systemFeatures: { enable_marketplace: enableMarketplace },
+ })
+}
const mockProviders = [
{
@@ -83,28 +83,40 @@ vi.mock('../system-model-selector', () => ({
default: () =>
,
}))
-vi.mock('@tanstack/react-query', async (importOriginal) => {
- const actual = await importOriginal()
+vi.mock('@/service/client', async (importOriginal) => {
+ const actual = await importOriginal()
+ const originalPlugins = actual.consoleQuery.plugins as unknown as Record
return {
...actual,
- useQuery: () => ({ data: undefined }),
+ consoleQuery: new Proxy(actual.consoleQuery, {
+ get(target, prop) {
+ if (prop === 'plugins') {
+ return {
+ ...originalPlugins,
+ checkInstalled: {
+ queryOptions: () => ({
+ queryKey: ['plugins', 'checkInstalled'],
+ queryFn: () => new Promise(() => {}),
+ }),
+ },
+ latestVersions: {
+ queryOptions: () => ({
+ queryKey: ['plugins', 'latestVersions'],
+ queryFn: () => new Promise(() => {}),
+ }),
+ },
+ }
+ }
+ return Reflect.get(target, prop)
+ },
+ }),
}
})
-vi.mock('@/service/client', () => ({
- consoleQuery: {
- plugins: {
- checkInstalled: { queryOptions: () => ({}) },
- latestVersions: { queryOptions: () => ({}) },
- },
- },
-}))
-
describe('ModelProviderPage', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
- mockEnableMarketplace = true
Object.keys(mockDefaultModels).forEach((key) => {
mockDefaultModels[key] = { data: null, isLoading: false }
})
@@ -134,21 +146,21 @@ describe('ModelProviderPage', () => {
})
it('should render main elements', () => {
- render( )
+ renderModelProviderPage()
expect(screen.getByText('common.modelProvider.models')).toBeInTheDocument()
expect(screen.getByTestId('system-model-selector')).toBeInTheDocument()
expect(screen.getByTestId('install-from-marketplace')).toBeInTheDocument()
})
it('should render configured and not configured providers sections', () => {
- render( )
+ renderModelProviderPage()
expect(screen.getByText('openai')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.toBeConfigured')).toBeInTheDocument()
expect(screen.getByText('anthropic')).toBeInTheDocument()
})
it('should filter providers based on search text', () => {
- render( )
+ renderModelProviderPage({ searchText: 'open' })
act(() => {
vi.advanceTimersByTime(600)
})
@@ -157,7 +169,7 @@ describe('ModelProviderPage', () => {
})
it('should show empty state if no configured providers match', () => {
- render( )
+ renderModelProviderPage({ searchText: 'non-existent' })
act(() => {
vi.advanceTimersByTime(600)
})
@@ -165,9 +177,7 @@ describe('ModelProviderPage', () => {
})
it('should hide marketplace section when marketplace feature is disabled', () => {
- mockEnableMarketplace = false
-
- render( )
+ renderModelProviderPage({ enableMarketplace: false })
expect(screen.queryByTestId('install-from-marketplace')).not.toBeInTheDocument()
})
@@ -185,14 +195,14 @@ describe('ModelProviderPage', () => {
},
})
- render( )
+ renderModelProviderPage()
expect(screen.queryByText('common.modelProvider.noneConfigured')).not.toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
expect(screen.getByText('common.modelProvider.emptyProviderTitle')).toBeInTheDocument()
})
it('should show none-configured warning when providers exist but no default models set', () => {
- render( )
+ renderModelProviderPage()
expect(screen.getByText('common.modelProvider.noneConfigured')).toBeInTheDocument()
})
@@ -202,7 +212,7 @@ describe('ModelProviderPage', () => {
isLoading: false,
}
- render( )
+ renderModelProviderPage()
expect(screen.getByText('common.modelProvider.notConfigured')).toBeInTheDocument()
})
@@ -217,7 +227,7 @@ describe('ModelProviderPage', () => {
mockDefaultModels.speech2text = makeModel('whisper-1', 'speech2text')
mockDefaultModels.tts = makeModel('tts-1', 'tts')
- render( )
+ renderModelProviderPage()
expect(screen.queryByText('common.modelProvider.noProviderInstalled')).not.toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.noneConfigured')).not.toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
@@ -228,7 +238,7 @@ describe('ModelProviderPage', () => {
mockDefaultModels[key] = { data: null, isLoading: true }
})
- render( )
+ renderModelProviderPage()
expect(screen.queryByText('common.modelProvider.noProviderInstalled')).not.toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.noneConfigured')).not.toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
@@ -265,7 +275,7 @@ describe('ModelProviderPage', () => {
},
})
- render( )
+ renderModelProviderPage()
const renderedProviders = screen.getAllByTestId('provider-card').map(item => item.textContent)
expect(renderedProviders).toEqual([
diff --git a/web/app/components/header/account-setting/model-provider-page/index.tsx b/web/app/components/header/account-setting/model-provider-page/index.tsx
index 06ab1776cb..6522ea2db4 100644
--- a/web/app/components/header/account-setting/model-provider-page/index.tsx
+++ b/web/app/components/header/account-setting/model-provider-page/index.tsx
@@ -3,15 +3,15 @@ import type {
} from './declarations'
import type { PluginDetail } from '@/app/components/plugins/types'
import { cn } from '@langgenius/dify-ui/cn'
-import { useQuery } from '@tanstack/react-query'
+import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { usePluginsWithLatestVersion } from '@/app/components/plugins/hooks'
import { IS_CLOUD_EDITION } from '@/config'
-import { useSystemFeaturesQuery } from '@/context/global-public-context'
import { useProviderContext } from '@/context/provider-context'
import { consoleQuery } from '@/service/client'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import {
CustomConfigurationStatusEnum,
ModelTypeEnum,
@@ -42,7 +42,7 @@ const ModelProviderPage = ({ searchText }: Props) => {
const { data: speech2textDefaultModel, isLoading: isSpeech2textDefaultModelLoading } = useDefaultModel(ModelTypeEnum.speech2text)
const { data: ttsDefaultModel, isLoading: isTTSDefaultModelLoading } = useDefaultModel(ModelTypeEnum.tts)
const { modelProviders: providers } = useProviderContext()
- const { data: systemFeatures } = useSystemFeaturesQuery()
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const allPluginIds = useMemo(() => {
return [...new Set(providers.map(p => providerToPluginId(p.provider)).filter(Boolean))]
@@ -59,7 +59,7 @@ const ModelProviderPage = ({ searchText }: Props) => {
map.set(plugin.plugin_id, plugin)
return map
}, [enrichedPlugins])
- const enableMarketplace = systemFeatures?.enable_marketplace ?? false
+ const enableMarketplace = systemFeatures.enable_marketplace
const isDefaultModelLoading = isTextGenerationDefaultModelLoading
|| isEmbeddingsDefaultModelLoading
|| isRerankDefaultModelLoading
diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx
index 61d6bb4466..318b5bcd73 100644
--- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx
+++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx
@@ -1,5 +1,8 @@
+import type { ReactElement } from 'react'
import type { Model, ModelItem, ModelProvider } from '../../declarations'
-import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import type { SystemFeatures } from '@/types/feature'
+import { fireEvent, screen, waitFor } from '@testing-library/react'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import {
ConfigurationMethodEnum,
ModelFeatureEnum,
@@ -59,11 +62,12 @@ vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({ modelProviders: mockContextModelProviders.current }),
}))
-vi.mock('@/context/global-public-context', () => ({
- useSystemFeaturesQuery: () => ({
- data: { trial_models: mockTrialModels.current },
- }),
-}))
+const renderPopup = (ui: ReactElement) => renderWithSystemFeatures(ui, {
+ // The Popup component never inspects trial_models beyond passing them
+ // through, so an opaque string[] is enough; cast to satisfy the
+ // ModelProviderQuotaGetPaid[] declared on SystemFeatures.
+ systemFeatures: { trial_models: mockTrialModels.current as unknown as SystemFeatures['trial_models'] },
+})
const mockTrialCredits = vi.hoisted(() => ({
credits: 200,
@@ -183,7 +187,7 @@ describe('Popup', () => {
})
it('should filter models by search and allow clearing search', () => {
- const { container } = render(
+ const { container } = renderPopup(
{
})
it('should not show compatible-only helper text when no scope features are applied', () => {
- render(
+ renderPopup(
{
})
it('should show compatible-only helper banner when scope features are applied', () => {
- const { container } = render(
+ const { container } = renderPopup(
{
]
mockSupportFunctionCall.mockReturnValue(false)
- const { unmount } = render(
+ const { unmount } = renderPopup(
{
unmount()
mockSupportFunctionCall.mockReturnValue(true)
- const { unmount: unmount2 } = render(
+ const { unmount: unmount2 } = renderPopup(
{
expect(screen.getByText('openai'))!.toBeInTheDocument()
unmount2()
- const { unmount: unmount3 } = render(
+ const { unmount: unmount3 } = renderPopup(
{
expect(screen.getByText('openai'))!.toBeInTheDocument()
unmount3()
- render(
+ renderPopup(
{
it('should match model labels from fallback languages when current language key is missing', () => {
mockLanguage = 'fr_FR'
- render(
+ renderPopup(
{
}),
]
- render(
+ renderPopup(
{
}),
]
- render(
+ renderPopup(
{
}),
]
- render(
+ renderPopup(
{
it('should open provider settings when clicking footer link', () => {
const onHide = vi.fn()
- render(
+ renderPopup(
{
it('should show empty state when no providers are configured', () => {
const onHide = vi.fn()
- render(
+ renderPopup(
{
it('should render marketplace providers that are not installed', () => {
mockContextModelProviders.current = [makeContextProvider({ provider: 'test-openai' })]
- render(
+ renderPopup(
{
} as MockContextProvider['system_configuration'],
})]
- render(
+ renderPopup(
{
} as MockContextProvider['system_configuration'],
})]
- render(
+ renderPopup(
{
})
it('should toggle marketplace section collapse', () => {
- render(
+ renderPopup(
{
]
mockInstallMutateAsync.mockResolvedValue({ all_installed: true, task_id: 'task-1' })
- render(
+ renderPopup(
{
]
mockInstallMutateAsync.mockRejectedValue(new Error('Install failed'))
- render(
+ renderPopup(
{
mockInstallMutateAsync.mockResolvedValue({ all_installed: false, task_id: 'task-1' })
mockCheck.mockResolvedValue(undefined)
- render(
+ renderPopup(
{
]
mockMarketplacePlugins.isLoading = true
- render(
+ renderPopup(
{
it('should skip install requests when the marketplace plugin cannot be found', async () => {
mockMarketplacePlugins.current = []
- render(
+ renderPopup(
{
})
it('should sort the selected provider to the top when a default model is provided', () => {
- render(
+ renderPopup(
= ({
const { refreshPluginList } = useRefreshPluginList()
const [installingProvider, setInstallingProvider] = useState(null)
const { isExhausted: isCreditsExhausted } = useTrialCredits()
- const { data: systemFeatures } = useSystemFeaturesQuery()
- const trialModels = systemFeatures?.trial_models
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
+ const trialModels = systemFeatures.trial_models
const installedProviderMap = useMemo(() => new Map(
modelProviders.map(provider => [provider.provider, provider]),
), [modelProviders])
diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/credential-panel.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/credential-panel.spec.tsx
index f07f9652f8..9d158a019e 100644
--- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/credential-panel.spec.tsx
+++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/credential-panel.spec.tsx
@@ -1,6 +1,6 @@
import type { ModelProvider } from '../../declarations'
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { act, fireEvent, screen, waitFor } from '@testing-library/react'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
@@ -28,10 +28,6 @@ vi.mock('@/config', async (importOriginal) => {
return { ...actual, IS_CLOUD_EDITION: true }
})
-vi.mock('@/context/global-public-context', () => ({
- useSystemFeaturesQuery: () => ({ data: { trial_models: ['langgenius/openai/openai'] } }),
-}))
-
vi.mock('@langgenius/dify-ui/toast', () => ({
default: { notify: mockToastNotify },
toast: {
@@ -42,24 +38,33 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
},
}))
-vi.mock('@/service/client', () => ({
- consoleQuery: {
- modelProviders: {
- models: {
- queryKey: ({ input }: { input: { params: { provider: string } } }) => ['console', 'modelProviders', 'models', input.params.provider],
- },
- changePreferredProviderType: {
- mutationOptions: (opts: Record) => ({
- mutationFn: (...args: unknown[]) => {
- mockChangePriorityFn(...args)
- return Promise.resolve({ result: 'success' })
- },
- ...opts,
- }),
- },
+vi.mock('@/service/client', async (importOriginal) => {
+ const actual = await importOriginal()
+ const mockedModelProviders = {
+ models: {
+ queryKey: ({ input }: { input: { params: { provider: string } } }) => ['console', 'modelProviders', 'models', input.params.provider],
},
- },
-}))
+ changePreferredProviderType: {
+ mutationOptions: (opts: Record) => ({
+ mutationFn: (...args: unknown[]) => {
+ mockChangePriorityFn(...args)
+ return Promise.resolve({ result: 'success' })
+ },
+ ...opts,
+ }),
+ },
+ }
+ return {
+ ...actual,
+ consoleQuery: new Proxy(actual.consoleQuery, {
+ get(target, prop) {
+ if (prop === 'modelProviders')
+ return mockedModelProviders
+ return Reflect.get(target, prop)
+ },
+ }),
+ }
+})
vi.mock('../../hooks', () => ({
useUpdateModelList: () => mockUpdateModelList,
@@ -88,13 +93,6 @@ vi.mock('@/app/components/base/icons/src/vender/line/alertsAndFeedback/Warning',
default: (props: Record) =>
,
}))
-const createTestQueryClient = () => new QueryClient({
- defaultOptions: {
- queries: { retry: false, gcTime: 0 },
- mutations: { retry: false },
- },
-})
-
const createProvider = (overrides: Partial = {}): ModelProvider => ({
provider: 'langgenius/openai/openai',
provider_credential_schema: { credential_form_schemas: [] },
@@ -112,12 +110,9 @@ const createProvider = (overrides: Partial = {}): ModelProvider =
} as unknown as ModelProvider)
const renderWithQueryClient = (provider: ModelProvider) => {
- const queryClient = createTestQueryClient()
- return render(
-
-
- ,
- )
+ return renderWithSystemFeatures( , {
+ systemFeatures: { trial_models: ['langgenius/openai/openai'] as never },
+ })
}
describe('CredentialPanel', () => {
diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/quota-panel.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/quota-panel.spec.tsx
index a3e527680f..db17ab7fc5 100644
--- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/quota-panel.spec.tsx
+++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/quota-panel.spec.tsx
@@ -1,5 +1,7 @@
+import type { ReactElement } from 'react'
import type { ModelProvider } from '../../declarations'
-import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { fireEvent, screen, waitFor } from '@testing-library/react'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import QuotaPanel from '../quota-panel'
let mockWorkspaceData: {
@@ -37,11 +39,9 @@ vi.mock('@/service/use-common', () => ({
}),
}))
-vi.mock('@/context/global-public-context', () => ({
- useSystemFeaturesQuery: () => ({
- data: mockTrialModels ? { trial_models: mockTrialModels } : undefined,
- }),
-}))
+const renderQuotaPanel = (ui: ReactElement) => renderWithSystemFeatures(ui, {
+ systemFeatures: mockTrialModels === undefined ? null : { trial_models: mockTrialModels as never },
+})
vi.mock('../../hooks', () => ({
useMarketplaceAllPlugins: () => ({
@@ -89,12 +89,12 @@ describe('QuotaPanel', () => {
mockWorkspaceData = undefined
mockWorkspaceIsPending = true
- render( )
+ renderQuotaPanel( )
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should show remaining credits and reset date', () => {
- render(
+ renderQuotaPanel(
,
@@ -108,7 +108,7 @@ describe('QuotaPanel', () => {
it('should keep quota content during background refetch when cached workspace exists', () => {
mockWorkspaceIsPending = true
- render( )
+ renderQuotaPanel( )
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByText('70')).toBeInTheDocument()
@@ -121,14 +121,14 @@ describe('QuotaPanel', () => {
next_credit_reset_date: '',
}
- render( )
+ renderQuotaPanel( )
expect(screen.getByText(/modelProvider\.card\.quotaExhausted/)).toBeInTheDocument()
expect(screen.queryByText(/modelProvider\.resetDate/)).not.toBeInTheDocument()
})
it('should open install modal when clicking an unsupported trial provider', () => {
- render( )
+ renderQuotaPanel( )
fireEvent.click(screen.getByText('openai'))
@@ -136,7 +136,7 @@ describe('QuotaPanel', () => {
})
it('should close install modal when provider becomes installed', async () => {
- const { rerender } = render( )
+ const { rerender } = renderQuotaPanel( )
fireEvent.click(screen.getByText('openai'))
expect(screen.getByText('install modal')).toBeInTheDocument()
@@ -151,13 +151,13 @@ describe('QuotaPanel', () => {
it('should tolerate missing trial model configuration', () => {
mockTrialModels = undefined
- render( )
+ renderQuotaPanel( )
expect(screen.queryByText('openai')).not.toBeInTheDocument()
})
it('should render installed custom providers without opening the install modal', () => {
- render( )
+ renderQuotaPanel( )
expect(screen.getByLabelText(/modelAPI/)).toBeInTheDocument()
@@ -167,7 +167,7 @@ describe('QuotaPanel', () => {
})
it('should show the supported-model tooltip for installed non-custom providers', () => {
- render(
+ renderQuotaPanel(
({
useTrialCredits: () => mockTrialCredits,
}))
-vi.mock('@/context/global-public-context', () => ({
- useSystemFeaturesQuery: () => ({ data: { trial_models: mockTrialModels } }),
-}))
-
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal()
return { ...actual, IS_CLOUD_EDITION: true }
})
+const renderPanelHook = (provider: ModelProvider | undefined) =>
+ renderHookWithSystemFeatures(() => useCredentialPanelState(provider), {
+ systemFeatures: { trial_models: mockTrialModels as never },
+ })
+
const createProvider = (overrides: Partial = {}): ModelProvider => ({
provider: 'langgenius/openai/openai',
provider_credential_schema: { credential_form_schemas: [] },
@@ -49,7 +50,7 @@ describe('useCredentialPanelState', () => {
// Credits priority variants
describe('Credits priority variants', () => {
it('should return credits-active when credits available', () => {
- const { result } = renderHook(() => useCredentialPanelState(createProvider()))
+ const { result } = renderPanelHook(createProvider())
expect(result.current.variant).toBe('credits-active')
expect(result.current.priority).toBe('credits')
@@ -60,7 +61,7 @@ describe('useCredentialPanelState', () => {
mockTrialCredits.isExhausted = true
mockTrialCredits.credits = 0
- const { result } = renderHook(() => useCredentialPanelState(createProvider()))
+ const { result } = renderPanelHook(createProvider())
expect(result.current.variant).toBe('api-fallback')
})
@@ -76,7 +77,7 @@ describe('useCredentialPanelState', () => {
},
})
- const { result } = renderHook(() => useCredentialPanelState(provider))
+ const { result } = renderPanelHook(provider)
expect(result.current.variant).toBe('no-usage')
})
@@ -90,7 +91,7 @@ describe('useCredentialPanelState', () => {
},
})
- const { result } = renderHook(() => useCredentialPanelState(provider))
+ const { result } = renderPanelHook(provider)
expect(result.current.variant).toBe('credits-exhausted')
})
@@ -103,7 +104,7 @@ describe('useCredentialPanelState', () => {
preferred_provider_type: PreferredProviderTypeEnum.custom,
})
- const { result } = renderHook(() => useCredentialPanelState(provider))
+ const { result } = renderPanelHook(provider)
expect(result.current.variant).toBe('api-active')
expect(result.current.priority).toBe('apiKey')
@@ -120,7 +121,7 @@ describe('useCredentialPanelState', () => {
},
})
- const { result } = renderHook(() => useCredentialPanelState(provider))
+ const { result } = renderPanelHook(provider)
expect(result.current.variant).toBe('credits-fallback')
})
@@ -134,7 +135,7 @@ describe('useCredentialPanelState', () => {
},
})
- const { result } = renderHook(() => useCredentialPanelState(provider))
+ const { result } = renderPanelHook(provider)
expect(result.current.variant).toBe('credits-fallback')
})
@@ -150,7 +151,7 @@ describe('useCredentialPanelState', () => {
},
})
- const { result } = renderHook(() => useCredentialPanelState(provider))
+ const { result } = renderPanelHook(provider)
expect(result.current.variant).toBe('no-usage')
})
@@ -168,7 +169,7 @@ describe('useCredentialPanelState', () => {
},
})
- const { result } = renderHook(() => useCredentialPanelState(provider))
+ const { result } = renderPanelHook(provider)
expect(result.current.variant).toBe('api-unavailable')
})
@@ -186,7 +187,7 @@ describe('useCredentialPanelState', () => {
},
})
- const { result } = renderHook(() => useCredentialPanelState(provider))
+ const { result } = renderPanelHook(provider)
expect(result.current.variant).toBe('api-required-configure')
})
@@ -199,7 +200,7 @@ describe('useCredentialPanelState', () => {
system_configuration: { enabled: false, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] },
})
- const { result } = renderHook(() => useCredentialPanelState(provider))
+ const { result } = renderPanelHook(provider)
expect(result.current.priority).toBe('apiKeyOnly')
expect(result.current.supportsCredits).toBe(false)
@@ -212,7 +213,7 @@ describe('useCredentialPanelState', () => {
preferred_provider_type: PreferredProviderTypeEnum.system,
})
- const { result } = renderHook(() => useCredentialPanelState(provider))
+ const { result } = renderPanelHook(provider)
expect(result.current.priority).toBe('apiKeyOnly')
expect(result.current.supportsCredits).toBe(false)
@@ -223,7 +224,7 @@ describe('useCredentialPanelState', () => {
// Undefined provider
describe('Undefined provider', () => {
it('should return safe defaults when provider is undefined', () => {
- const { result } = renderHook(() => useCredentialPanelState(undefined))
+ const { result } = renderPanelHook(undefined)
expect(result.current.priority).toBe('apiKeyOnly')
expect(result.current.supportsCredits).toBe(false)
@@ -237,7 +238,7 @@ describe('useCredentialPanelState', () => {
it('should show priority switcher when credits supported and custom config active', () => {
const provider = createProvider()
- const { result } = renderHook(() => useCredentialPanelState(provider))
+ const { result } = renderPanelHook(provider)
expect(result.current.showPrioritySwitcher).toBe(true)
})
@@ -247,7 +248,7 @@ describe('useCredentialPanelState', () => {
system_configuration: { enabled: false, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] },
})
- const { result } = renderHook(() => useCredentialPanelState(provider))
+ const { result } = renderPanelHook(provider)
expect(result.current.showPrioritySwitcher).toBe(false)
})
@@ -258,13 +259,13 @@ describe('useCredentialPanelState', () => {
system_configuration: { enabled: true, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] },
})
- const { result } = renderHook(() => useCredentialPanelState(provider))
+ const { result } = renderPanelHook(provider)
expect(result.current.showPrioritySwitcher).toBe(false)
})
it('should expose credential name from provider', () => {
- const { result } = renderHook(() => useCredentialPanelState(createProvider()))
+ const { result } = renderPanelHook(createProvider())
expect(result.current.credentialName).toBe('My Key')
})
@@ -272,7 +273,7 @@ describe('useCredentialPanelState', () => {
it('should expose credits amount', () => {
mockTrialCredits.credits = 500
- const { result } = renderHook(() => useCredentialPanelState(createProvider()))
+ const { result } = renderPanelHook(createProvider())
expect(result.current.credits).toBe(500)
})
diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.tsx
index 7828dc4635..bbc2cfaa98 100644
--- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.tsx
+++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/quota-panel.tsx
@@ -4,14 +4,15 @@ import type { Plugin } from '@/app/components/plugins/types'
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
-import { useSystemFeaturesQuery } from '@/context/global-public-context'
import useTimestamp from '@/hooks/use-timestamp'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { formatNumber } from '@/utils/format'
import { PreferredProviderTypeEnum } from '../declarations'
import { useMarketplaceAllPlugins } from '../hooks'
@@ -32,8 +33,8 @@ const QuotaPanel: FC = ({
}) => {
const { t } = useTranslation()
const { credits, isExhausted, isLoading, nextCreditResetDate } = useTrialCredits()
- const { data: systemFeatures } = useSystemFeaturesQuery()
- const trialModels = systemFeatures?.trial_models ?? []
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
+ const trialModels = systemFeatures.trial_models
const providerMap = useMemo(() => new Map(
providers.map(p => [p.provider, p.preferred_provider_type]),
), [providers])
diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state.ts b/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state.ts
index c9ca9d105f..e8a5f0f8a5 100644
--- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state.ts
+++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state.ts
@@ -1,6 +1,7 @@
import type { ModelProvider } from '../declarations'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { useCredentialStatus } from '@/app/components/header/account-setting/model-provider-page/model-auth/hooks'
-import { useSystemFeaturesQuery } from '@/context/global-public-context'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import {
PreferredProviderTypeEnum,
} from '../declarations'
@@ -79,8 +80,8 @@ export function useCredentialPanelState(provider: ModelProvider | undefined): Cr
current_credential_name,
} = useCredentialStatus(provider)
- const { data: systemFeatures } = useSystemFeaturesQuery()
- const trialModels = systemFeatures?.trial_models
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
+ const trialModels = systemFeatures.trial_models
const preferredType = provider?.preferred_provider_type
diff --git a/web/app/components/header/index.tsx b/web/app/components/header/index.tsx
index 560d5f1eaa..c38fd977d8 100644
--- a/web/app/components/header/index.tsx
+++ b/web/app/components/header/index.tsx
@@ -1,15 +1,16 @@
'use client'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback } from 'react'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import WorkplaceSelector from '@/app/components/header/account-dropdown/workplace-selector'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
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 { WorkspaceProvider } from '@/context/workspace-context-provider'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import Link from '@/next/link'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { Plan } from '../billing/type'
import AccountDropdown from './account-dropdown'
import AppNav from './app-nav'
@@ -33,7 +34,7 @@ const Header = () => {
const isMobile = media === MediaType.mobile
const { enableBilling, plan } = useProviderContext()
const { setShowPricingModal, setShowAccountSettingModal } = useModalContext()
- const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isFreePlan = plan.type === Plan.sandbox
const isBrandingEnabled = systemFeatures.branding.enabled
const handlePlanClick = useCallback(() => {
diff --git a/web/app/components/header/license-env/__tests__/index.spec.tsx b/web/app/components/header/license-env/__tests__/index.spec.tsx
index cec4af60d9..ec8959da78 100644
--- a/web/app/components/header/license-env/__tests__/index.spec.tsx
+++ b/web/app/components/header/license-env/__tests__/index.spec.tsx
@@ -1,7 +1,7 @@
-import { render, screen } from '@testing-library/react'
+import { screen } from '@testing-library/react'
import dayjs from 'dayjs'
-import { useGlobalPublicStore } from '@/context/global-public-context'
-import { defaultSystemFeatures, LicenseStatus } from '@/types/feature'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
+import { LicenseStatus } from '@/types/feature'
import LicenseNav from '../index'
describe('LicenseNav', () => {
@@ -10,9 +10,6 @@ describe('LicenseNav', () => {
vi.useFakeTimers()
const now = new Date('2024-01-01T12:00:00Z')
vi.setSystemTime(now)
- useGlobalPublicStore.setState({
- systemFeatures: defaultSystemFeatures,
- })
})
afterEach(() => {
@@ -20,72 +17,60 @@ describe('LicenseNav', () => {
})
it('should render null when license status is NONE', () => {
- const { container } = render( )
+ const { container } = renderWithSystemFeatures( )
expect(container).toBeEmptyDOMElement()
})
it('should render Enterprise badge when license status is ACTIVE', () => {
- useGlobalPublicStore.setState({
+ renderWithSystemFeatures( , {
systemFeatures: {
- ...defaultSystemFeatures,
license: {
status: LicenseStatus.ACTIVE,
expired_at: null,
},
},
})
-
- render( )
expect(screen.getByText('Enterprise')).toBeInTheDocument()
})
it('should render singular expiring message when license expires in 0 days', () => {
const expiredAt = dayjs().add(2, 'hours').toISOString()
- useGlobalPublicStore.setState({
+ renderWithSystemFeatures( , {
systemFeatures: {
- ...defaultSystemFeatures,
license: {
status: LicenseStatus.EXPIRING,
expired_at: expiredAt,
},
},
})
-
- render( )
expect(screen.getByText(/license\.expiring/)).toBeInTheDocument()
expect(screen.getByText(/count":0/)).toBeInTheDocument()
})
it('should render singular expiring message when license expires in 1 day', () => {
const tomorrow = dayjs().add(1, 'day').add(1, 'hour').toISOString()
- useGlobalPublicStore.setState({
+ renderWithSystemFeatures( , {
systemFeatures: {
- ...defaultSystemFeatures,
license: {
status: LicenseStatus.EXPIRING,
expired_at: tomorrow,
},
},
})
-
- render( )
expect(screen.getByText(/license\.expiring/)).toBeInTheDocument()
expect(screen.getByText(/count":1/)).toBeInTheDocument()
})
it('should render plural expiring message when license expires in 5 days', () => {
const fiveDaysLater = dayjs().add(5, 'day').add(1, 'hour').toISOString()
- useGlobalPublicStore.setState({
+ renderWithSystemFeatures( , {
systemFeatures: {
- ...defaultSystemFeatures,
license: {
status: LicenseStatus.EXPIRING,
expired_at: fiveDaysLater,
},
},
})
-
- render( )
expect(screen.getByText(/license\.expiring_plural/)).toBeInTheDocument()
expect(screen.getByText(/count":5/)).toBeInTheDocument()
})
diff --git a/web/app/components/header/license-env/index.tsx b/web/app/components/header/license-env/index.tsx
index 2e505ddd41..fd360796b1 100644
--- a/web/app/components/header/license-env/index.tsx
+++ b/web/app/components/header/license-env/index.tsx
@@ -1,15 +1,16 @@
'use client'
import { RiHourglass2Fill } from '@remixicon/react'
+import { useSuspenseQuery } from '@tanstack/react-query'
import dayjs from 'dayjs'
import { useTranslation } from 'react-i18next'
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { LicenseStatus } from '@/types/feature'
import PremiumBadge from '../../base/premium-badge'
const LicenseNav = () => {
const { t } = useTranslation()
- const { systemFeatures } = useGlobalPublicStore()
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
if (systemFeatures.license?.status === LicenseStatus.EXPIRING) {
const expiredAt = systemFeatures.license?.expired_at
diff --git a/web/app/components/plugins/install-plugin/hooks/__tests__/use-install-plugin-limit.spec.ts b/web/app/components/plugins/install-plugin/hooks/__tests__/use-install-plugin-limit.spec.ts
index fa01b63b5a..c6881e5c88 100644
--- a/web/app/components/plugins/install-plugin/hooks/__tests__/use-install-plugin-limit.spec.ts
+++ b/web/app/components/plugins/install-plugin/hooks/__tests__/use-install-plugin-limit.spec.ts
@@ -1,20 +1,8 @@
-import { renderHook } from '@testing-library/react'
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, it } from 'vitest'
+import { renderHookWithSystemFeatures as renderHook } from '@/__tests__/utils/mock-system-features'
import { InstallationScope } from '@/types/feature'
import { pluginInstallLimit } from '../use-install-plugin-limit'
-const mockSystemFeatures = {
- plugin_installation_permission: {
- restrict_to_marketplace_only: false,
- plugin_installation_scope: InstallationScope.ALL,
- },
-}
-
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: (selector: (state: { systemFeatures: typeof mockSystemFeatures }) => unknown) =>
- selector({ systemFeatures: mockSystemFeatures }),
-}))
-
const basePlugin = {
from: 'marketplace' as const,
verification: { authorized_category: 'langgenius' },
diff --git a/web/app/components/plugins/install-plugin/hooks/use-install-plugin-limit.tsx b/web/app/components/plugins/install-plugin/hooks/use-install-plugin-limit.tsx
index 227c572a77..ccc28c74c1 100644
--- a/web/app/components/plugins/install-plugin/hooks/use-install-plugin-limit.tsx
+++ b/web/app/components/plugins/install-plugin/hooks/use-install-plugin-limit.tsx
@@ -1,6 +1,7 @@
import type { Plugin, PluginManifestInMarket } from '../../types'
import type { SystemFeatures } from '@/types/feature'
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { useSuspenseQuery } from '@tanstack/react-query'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { InstallationScope } from '@/types/feature'
type PluginProps = (Plugin | PluginManifestInMarket) & { from: 'github' | 'marketplace' | 'package' }
@@ -41,6 +42,6 @@ export function pluginInstallLimit(plugin: PluginProps, systemFeatures: SystemFe
}
export default function usePluginInstallLimit(plugin: PluginProps) {
- const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
return pluginInstallLimit(plugin, systemFeatures)
}
diff --git a/web/app/components/plugins/install-plugin/install-bundle/__tests__/index.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/__tests__/index.spec.tsx
index fee88913b7..dd3d63aa32 100644
--- a/web/app/components/plugins/install-plugin/install-bundle/__tests__/index.spec.tsx
+++ b/web/app/components/plugins/install-plugin/install-bundle/__tests__/index.spec.tsx
@@ -1,6 +1,7 @@
import type { Dependency, GitHubItemAndMarketPlaceDependency, InstallStatus, PackageDependency, Plugin, PluginDeclaration, VersionProps } from '../../../types'
-import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
import { InstallStep, PluginCategoryEnum } from '../../../types'
import InstallBundle, { InstallType } from '../index'
import GithubItem from '../item/github-item'
@@ -183,11 +184,6 @@ vi.mock('@/context/mitt-context', () => ({
useMittContextSelector: () => vi.fn(),
}))
-// Mock global public context
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: () => ({}),
-}))
-
// Mock useCanInstallPluginFromMarketplace
vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
useCanInstallPluginFromMarketplace: () => ({ canInstallPluginFromMarketplace: true }),
diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install-multi.spec.tsx b/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install-multi.spec.tsx
index d45b05bfd1..5a2bbf7ea9 100644
--- a/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install-multi.spec.tsx
+++ b/web/app/components/plugins/install-plugin/install-bundle/steps/__tests__/install-multi.spec.tsx
@@ -1,7 +1,8 @@
import type { Dependency, GitHubItemAndMarketPlaceDependency, PackageDependency, Plugin, VersionInfo } from '../../../../types'
-import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
import { PluginCategoryEnum } from '../../../../types'
import InstallMulti from '../install-multi'
@@ -56,11 +57,6 @@ vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', ()
}),
}))
-// Mock useGlobalPublicStore
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: () => ({}),
-}))
-
// Mock pluginInstallLimit (imported by the useInstallMultiState hook via @/ path)
vi.mock('@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit', () => ({
pluginInstallLimit: () => ({ canInstall: true }),
diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/__tests__/use-install-multi-state.spec.ts b/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/__tests__/use-install-multi-state.spec.ts
index 47f85d35cf..9506ca1b0b 100644
--- a/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/__tests__/use-install-multi-state.spec.ts
+++ b/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/__tests__/use-install-multi-state.spec.ts
@@ -1,6 +1,7 @@
import type { Dependency, GitHubItemAndMarketPlaceDependency, PackageDependency, Plugin, VersionInfo } from '@/app/components/plugins/types'
-import { act, renderHook, waitFor } from '@testing-library/react'
+import { act, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { renderHookWithSystemFeatures as renderHook } from '@/__tests__/utils/mock-system-features'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { getPluginKey, useInstallMultiState } from '../use-install-multi-state'
@@ -23,10 +24,6 @@ vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', ()
}),
}))
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: () => ({}),
-}))
-
vi.mock('@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit', () => ({
pluginInstallLimit: () => ({ canInstall: mockCanInstall }),
}))
diff --git a/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/use-install-multi-state.ts b/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/use-install-multi-state.ts
index ca06f33c16..196f453325 100644
--- a/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/use-install-multi-state.ts
+++ b/web/app/components/plugins/install-plugin/install-bundle/steps/hooks/use-install-multi-state.ts
@@ -1,10 +1,11 @@
'use client'
import type { Dependency, GitHubItemAndMarketPlaceDependency, PackageDependency, Plugin, VersionInfo } from '@/app/components/plugins/types'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useState } from 'react'
import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
import { pluginInstallLimit } from '@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit'
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useFetchPluginsInMarketPlaceByInfo } from '@/service/use-plugins'
type UseInstallMultiStateParams = {
@@ -86,7 +87,7 @@ export function useInstallMultiState({
onSelect,
onLoadedAllPlugin,
}: UseInstallMultiStateParams) {
- const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
// Marketplace plugins filtering and index mapping
const marketplacePlugins = useMemo(
diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/detail-header.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/detail-header.spec.tsx
index 8e04db6299..e21e925af5 100644
--- a/web/app/components/plugins/plugin-detail-panel/__tests__/detail-header.spec.tsx
+++ b/web/app/components/plugins/plugin-detail-panel/__tests__/detail-header.spec.tsx
@@ -1,10 +1,19 @@
+import type { ReactElement } from 'react'
import type { PluginDetail } from '../../types'
-import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import * as amplitude from '@/app/components/base/amplitude'
import { PluginSource } from '../../types'
import DetailHeader from '../detail-header'
+let mockEnableMarketplace = true
+
+const render = (ui: ReactElement) =>
+ renderWithSystemFeatures(ui, {
+ systemFeatures: { enable_marketplace: mockEnableMarketplace },
+ })
+
const { mockToast } = vi.hoisted(() => ({
mockToast: Object.assign(vi.fn(), {
success: vi.fn(),
@@ -70,13 +79,7 @@ vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
}))
-// Global mock state for enable_marketplace
-let mockEnableMarketplace = true
-
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => unknown) =>
- selector({ systemFeatures: { enable_marketplace: mockEnableMarketplace } }),
-}))
+// Global mock state for enable_marketplace seeded into the QueryClient via the local render helper.
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
diff --git a/web/app/components/plugins/plugin-detail-panel/__tests__/operation-dropdown.spec.tsx b/web/app/components/plugins/plugin-detail-panel/__tests__/operation-dropdown.spec.tsx
index db6ff57957..5a870c6c82 100644
--- a/web/app/components/plugins/plugin-detail-panel/__tests__/operation-dropdown.spec.tsx
+++ b/web/app/components/plugins/plugin-detail-panel/__tests__/operation-dropdown.spec.tsx
@@ -1,14 +1,13 @@
import type { ReactElement, ReactNode } from 'react'
-import { fireEvent, render, screen } from '@testing-library/react'
+import { fireEvent, screen } from '@testing-library/react'
import { cloneElement } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { PluginSource } from '../../types'
import OperationDropdown from '../operation-dropdown'
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => T): T =>
- selector({ systemFeatures: { enable_marketplace: true } }),
-}))
+const render = (ui: ReactElement) =>
+ renderWithSystemFeatures(ui, { systemFeatures: { enable_marketplace: true } })
vi.mock('@langgenius/dify-ui/cn', () => ({
cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/use-detail-header-state.spec.ts b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/use-detail-header-state.spec.ts
index 044d03ca61..04eb78ad98 100644
--- a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/use-detail-header-state.spec.ts
+++ b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/__tests__/use-detail-header-state.spec.ts
@@ -1,14 +1,16 @@
import type { PluginDetail } from '../../../../types'
-import { act, renderHook } from '@testing-library/react'
+import { act } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { renderHookWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { PluginSource } from '../../../../types'
import { useDetailHeaderState } from '../use-detail-header-state'
let mockEnableMarketplace = true
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => unknown) =>
- selector({ systemFeatures: { enable_marketplace: mockEnableMarketplace } }),
-}))
+
+const renderHook = (callback: () => Result) =>
+ renderHookWithSystemFeatures(callback, {
+ systemFeatures: { enable_marketplace: mockEnableMarketplace },
+ })
let mockAutoUpgradeInfo: {
strategy_setting: string
diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-detail-header-state.ts b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-detail-header-state.ts
index 3af7fc4173..8ebcbe0dc1 100644
--- a/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-detail-header-state.ts
+++ b/web/app/components/plugins/plugin-detail-panel/detail-header/hooks/use-detail-header-state.ts
@@ -1,9 +1,10 @@
'use client'
import type { PluginDetail } from '../../../types'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { useBoolean } from 'ahooks'
import { useCallback, useMemo, useState } from 'react'
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import useReferenceSetting from '../../../plugin-page/use-reference-setting'
import { AUTO_UPDATE_MODE } from '../../../reference-setting-modal/auto-update-setting/types'
import { PluginSource } from '../../../types'
@@ -48,7 +49,10 @@ type UseDetailHeaderStateReturn = {
}
export const useDetailHeaderState = (detail: PluginDetail): UseDetailHeaderStateReturn => {
- const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
+ const { data: enable_marketplace } = useSuspenseQuery({
+ ...systemFeaturesQueryOptions(),
+ select: s => s.enable_marketplace,
+ })
const { referenceSetting } = useReferenceSetting()
const {
diff --git a/web/app/components/plugins/plugin-detail-panel/operation-dropdown.tsx b/web/app/components/plugins/plugin-detail-panel/operation-dropdown.tsx
index c7a1529a3e..76ed8799d2 100644
--- a/web/app/components/plugins/plugin-detail-panel/operation-dropdown.tsx
+++ b/web/app/components/plugins/plugin-detail-panel/operation-dropdown.tsx
@@ -9,9 +9,10 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
+import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { PluginSource } from '../types'
type Props = {
@@ -39,7 +40,10 @@ const OperationDropdown: FC = ({
}) => {
const { t } = useTranslation()
const [open, setOpen] = React.useState(false)
- const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
+ const { data: enable_marketplace } = useSuspenseQuery({
+ ...systemFeaturesQueryOptions(),
+ select: s => s.enable_marketplace,
+ })
return (
diff --git a/web/app/components/plugins/plugin-item/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-item/__tests__/index.spec.tsx
index 39f3915f99..9d713042cc 100644
--- a/web/app/components/plugins/plugin-item/__tests__/index.spec.tsx
+++ b/web/app/components/plugins/plugin-item/__tests__/index.spec.tsx
@@ -1,9 +1,18 @@
+import type { ReactElement } from 'react'
import type { PluginDeclaration, PluginDetail } from '../../types'
-import { fireEvent, render, screen } from '@testing-library/react'
+import { fireEvent, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { PluginCategoryEnum, PluginSource } from '../../types'
import PluginItem from '../index'
+const mockEnableMarketplace = vi.fn(() => true)
+
+const render = (ui: ReactElement) =>
+ renderWithSystemFeatures(ui, {
+ systemFeatures: { enable_marketplace: mockEnableMarketplace() },
+ })
+
const mockTheme = vi.fn(() => 'light')
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: mockTheme() }),
@@ -54,12 +63,6 @@ vi.mock('@/context/app-context', () => ({
}),
}))
-const mockEnableMarketplace = vi.fn(() => true)
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: (selector: (s: Record) => unknown) =>
- selector({ systemFeatures: { enable_marketplace: mockEnableMarketplace() } }),
-}))
-
vi.mock('../action', () => ({
default: ({ onDelete, pluginName }: { onDelete: () => void, pluginName: string }) => (
diff --git a/web/app/components/plugins/plugin-item/index.tsx b/web/app/components/plugins/plugin-item/index.tsx
index 0f19db81e6..5843dffbe9 100644
--- a/web/app/components/plugins/plugin-item/index.tsx
+++ b/web/app/components/plugins/plugin-item/index.tsx
@@ -9,6 +9,7 @@ import {
RiHardDrive3Line,
RiLoginCircleLine,
} from '@remixicon/react'
+import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@@ -16,9 +17,9 @@ import Tooltip from '@/app/components/base/tooltip'
import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list'
import { API_PREFIX } from '@/config'
import { useAppContext } from '@/context/app-context'
-import { useGlobalPublicStore } from '@/context/global-public-context'
import { useRenderI18nObject } from '@/hooks/use-i18n'
import useTheme from '@/hooks/use-theme'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { isEqualOrLaterThanVersion } from '@/utils/semver'
import { getMarketplaceUrl } from '@/utils/var'
import Badge from '../../base/badge'
@@ -85,7 +86,10 @@ const PluginItem: FC
= ({
const getValueFromI18nObject = useRenderI18nObject()
const title = getValueFromI18nObject(label)
const descriptionText = getValueFromI18nObject(description)
- const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
+ const { data: enable_marketplace } = useSuspenseQuery({
+ ...systemFeaturesQueryOptions(),
+ select: s => s.enable_marketplace,
+ })
const iconFileName = theme === 'dark' && icon_dark ? icon_dark : icon
const iconSrc = iconFileName
? (iconFileName.startsWith('http') ? iconFileName : `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenant_id}&filename=${iconFileName}`)
diff --git a/web/app/components/plugins/plugin-page/__tests__/context-provider.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/context-provider.spec.tsx
index 476ab8e145..ea56d75fc9 100644
--- a/web/app/components/plugins/plugin-page/__tests__/context-provider.spec.tsx
+++ b/web/app/components/plugins/plugin-page/__tests__/context-provider.spec.tsx
@@ -1,14 +1,11 @@
-import { fireEvent, screen } from '@testing-library/react'
+import type { ReactElement, ReactNode } from 'react'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { useGlobalPublicStore } from '@/context/global-public-context'
-import { renderWithNuqs } from '@/test/nuqs-testing'
+import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
import { usePluginPageContext } from '../context'
import { PluginPageContextProvider } from '../context-provider'
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: vi.fn(),
-}))
-
vi.mock('../../hooks', () => ({
PLUGIN_PAGE_TABS_MAP: {
plugins: 'plugins',
@@ -20,11 +17,21 @@ vi.mock('../../hooks', () => ({
],
}))
-const mockGlobalPublicStore = (enableMarketplace: boolean) => {
- vi.mocked(useGlobalPublicStore).mockImplementation((selector) => {
- const state = { systemFeatures: { enable_marketplace: enableMarketplace } }
- return selector(state as Parameters[0])
+const renderWithProviders = (
+ ui: ReactElement,
+ options: { enableMarketplace: boolean, searchParams?: string } = { enableMarketplace: true },
+) => {
+ const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
+ systemFeatures: { enable_marketplace: options.enableMarketplace },
})
+ const Wrapper = ({ children }: { children: ReactNode }) => (
+
+
+ {children}
+
+
+ )
+ return render(ui, { wrapper: Wrapper })
}
const Consumer = () => {
@@ -47,25 +54,22 @@ describe('PluginPageContextProvider', () => {
})
it('filters out the marketplace tab when the feature is disabled', () => {
- mockGlobalPublicStore(false)
-
- renderWithNuqs(
+ renderWithProviders(
,
+ { enableMarketplace: false },
)
expect(screen.getByTestId('options-count')).toHaveTextContent('1')
})
it('keeps the query-state tab and updates the current plugin id', () => {
- mockGlobalPublicStore(true)
-
- renderWithNuqs(
+ renderWithProviders(
,
- { searchParams: '?tab=discover' },
+ { enableMarketplace: true, searchParams: '?tab=discover' },
)
fireEvent.click(screen.getByText('select plugin'))
diff --git a/web/app/components/plugins/plugin-page/__tests__/context.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/context.spec.tsx
index 389c161e8a..e534776a85 100644
--- a/web/app/components/plugins/plugin-page/__tests__/context.spec.tsx
+++ b/web/app/components/plugins/plugin-page/__tests__/context.spec.tsx
@@ -1,7 +1,7 @@
-import { render, screen } from '@testing-library/react'
+import type { ReactElement } from 'react'
+import { screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-// Import mocks
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { PluginPageContext, usePluginPageContext } from '../context'
import { PluginPageContextProvider } from '../context-provider'
@@ -14,10 +14,6 @@ vi.mock('nuqs', () => ({
useQueryState: vi.fn(() => ['plugins', vi.fn()]),
}))
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: vi.fn(),
-}))
-
vi.mock('../../hooks', () => ({
PLUGIN_PAGE_TABS_MAP: {
plugins: 'plugins',
@@ -29,13 +25,10 @@ vi.mock('../../hooks', () => ({
],
}))
-// Helper function to mock useGlobalPublicStore with marketplace setting
-const mockGlobalPublicStore = (enableMarketplace: boolean) => {
- vi.mocked(useGlobalPublicStore).mockImplementation((selector) => {
- const state = { systemFeatures: { enable_marketplace: enableMarketplace } }
- return selector(state as Parameters[0])
+const renderWithMarketplace = (ui: ReactElement, enableMarketplace: boolean) =>
+ renderWithSystemFeatures(ui, {
+ systemFeatures: { enable_marketplace: enableMarketplace },
})
-}
// Test component that uses the context
const TestConsumer = () => {
@@ -62,12 +55,11 @@ describe('PluginPageContext', () => {
describe('PluginPageContextProvider', () => {
it('should provide context values to children', () => {
- mockGlobalPublicStore(true)
-
- render(
+ renderWithMarketplace(
,
+ true,
)
expect(screen.getByTestId('has-container-ref')).toHaveTextContent('true')
@@ -75,12 +67,11 @@ describe('PluginPageContext', () => {
})
it('should include marketplace tab when enable_marketplace is true', () => {
- mockGlobalPublicStore(true)
-
- render(
+ renderWithMarketplace(
,
+ true,
)
expect(screen.getByTestId('option-plugins')).toBeInTheDocument()
@@ -88,12 +79,11 @@ describe('PluginPageContext', () => {
})
it('should filter out marketplace tab when enable_marketplace is false', () => {
- mockGlobalPublicStore(false)
-
- render(
+ renderWithMarketplace(
,
+ false,
)
expect(screen.getByTestId('option-plugins')).toBeInTheDocument()
@@ -104,12 +94,11 @@ describe('PluginPageContext', () => {
describe('usePluginPageContext', () => {
it('should select specific context values', () => {
- mockGlobalPublicStore(true)
-
- render(
+ renderWithMarketplace(
,
+ true,
)
// activeTab should be 'plugins' from the mock
diff --git a/web/app/components/plugins/plugin-page/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/index.spec.tsx
index e02a2bcb57..067cea0a42 100644
--- a/web/app/components/plugins/plugin-page/__tests__/index.spec.tsx
+++ b/web/app/components/plugins/plugin-page/__tests__/index.spec.tsx
@@ -1,8 +1,10 @@
+import type { ReactElement } from 'react'
import type { PluginPageProps } from '../index'
-import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { useQueryState } from 'nuqs'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { usePluginInstallation } from '@/hooks/use-query-params'
// Import mocked modules for assertions
import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins'
@@ -10,6 +12,12 @@ import PluginPageWithContext from '../index'
let mockEnableMarketplace = true
+const render = (ui: ReactElement, options: Parameters[1] = {}) =>
+ renderWithSystemFeatures(ui, {
+ systemFeatures: { enable_marketplace: mockEnableMarketplace },
+ ...options,
+ })
+
// Mock external dependencies
vi.mock('@/service/plugins', () => ({
fetchManifestFromMarketPlace: vi.fn(),
@@ -29,17 +37,6 @@ vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: vi.fn((selector) => {
- const state = {
- systemFeatures: {
- enable_marketplace: mockEnableMarketplace,
- },
- }
- return selector(state)
- }),
-}))
-
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: true,
diff --git a/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx b/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx
index 2dd884e18b..6d6784dea8 100644
--- a/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx
+++ b/web/app/components/plugins/plugin-page/__tests__/install-plugin-dropdown.spec.tsx
@@ -1,5 +1,7 @@
-import { fireEvent, render, screen } from '@testing-library/react'
+import type { ReactElement } from 'react'
+import { fireEvent, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import InstallPluginDropdown from '../install-plugin-dropdown'
let portalOpen = false
@@ -14,14 +16,16 @@ const {
},
}))
-vi.mock('@/config', () => ({
- SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS: '.difypkg,.zip',
-}))
+vi.mock('@/config', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS: '.difypkg,.zip',
+ }
+})
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: (selector: (state: { systemFeatures: typeof mockSystemFeatures }) => unknown) =>
- selector({ systemFeatures: mockSystemFeatures }),
-}))
+const render = (ui: ReactElement) =>
+ renderWithSystemFeatures(ui, { systemFeatures: mockSystemFeatures })
vi.mock('@/app/components/base/icons/src/vender/solid/files', () => ({
FileZip: () => file ,
diff --git a/web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts b/web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts
index efb31b5afb..f195be3e78 100644
--- a/web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts
+++ b/web/app/components/plugins/plugin-page/__tests__/use-reference-setting.spec.ts
@@ -1,9 +1,9 @@
// Import mocks for assertions
import { toast } from '@langgenius/dify-ui/toast'
-import { renderHook, waitFor } from '@testing-library/react'
+import { waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { renderHookWithSystemFeatures as renderHook } from '@/__tests__/utils/mock-system-features'
import { useAppContext } from '@/context/app-context'
-import { useGlobalPublicStore } from '@/context/global-public-context'
import { useInvalidateReferenceSettings, useMutationReferenceSettings, useReferenceSettings } from '@/service/use-plugins'
import { PermissionType } from '../../types'
@@ -13,10 +13,6 @@ vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: vi.fn(),
-}))
-
vi.mock('@/service/use-plugins', () => ({
useReferenceSettings: vi.fn(),
useMutationReferenceSettings: vi.fn(),
@@ -296,45 +292,22 @@ describe('useCanInstallPluginFromMarketplace Hook', () => {
})
it('should return true when marketplace is enabled and canManagement is true', () => {
- vi.mocked(useGlobalPublicStore).mockImplementation((selector) => {
- const state = {
- systemFeatures: {
- enable_marketplace: true,
- },
- }
- return selector(state as Parameters[0])
+ const { result } = renderHook(() => useCanInstallPluginFromMarketplace(), {
+ systemFeatures: { enable_marketplace: true },
})
- const { result } = renderHook(() => useCanInstallPluginFromMarketplace())
-
expect(result.current.canInstallPluginFromMarketplace).toBe(true)
})
it('should return false when marketplace is disabled', () => {
- vi.mocked(useGlobalPublicStore).mockImplementation((selector) => {
- const state = {
- systemFeatures: {
- enable_marketplace: false,
- },
- }
- return selector(state as Parameters[0])
+ const { result } = renderHook(() => useCanInstallPluginFromMarketplace(), {
+ systemFeatures: { enable_marketplace: false },
})
- const { result } = renderHook(() => useCanInstallPluginFromMarketplace())
-
expect(result.current.canInstallPluginFromMarketplace).toBe(false)
})
it('should return false when canManagement is false', () => {
- vi.mocked(useGlobalPublicStore).mockImplementation((selector) => {
- const state = {
- systemFeatures: {
- enable_marketplace: true,
- },
- }
- return selector(state as Parameters[0])
- })
-
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
@@ -344,21 +317,14 @@ describe('useCanInstallPluginFromMarketplace Hook', () => {
},
} as ReturnType)
- const { result } = renderHook(() => useCanInstallPluginFromMarketplace())
+ const { result } = renderHook(() => useCanInstallPluginFromMarketplace(), {
+ systemFeatures: { enable_marketplace: true },
+ })
expect(result.current.canInstallPluginFromMarketplace).toBe(false)
})
it('should return false when both marketplace is disabled and canManagement is false', () => {
- vi.mocked(useGlobalPublicStore).mockImplementation((selector) => {
- const state = {
- systemFeatures: {
- enable_marketplace: false,
- },
- }
- return selector(state as Parameters[0])
- })
-
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
@@ -368,7 +334,9 @@ describe('useCanInstallPluginFromMarketplace Hook', () => {
},
} as ReturnType)
- const { result } = renderHook(() => useCanInstallPluginFromMarketplace())
+ const { result } = renderHook(() => useCanInstallPluginFromMarketplace(), {
+ systemFeatures: { enable_marketplace: false },
+ })
expect(result.current.canInstallPluginFromMarketplace).toBe(false)
})
diff --git a/web/app/components/plugins/plugin-page/context-provider.tsx b/web/app/components/plugins/plugin-page/context-provider.tsx
index 83776b48f9..6347fde86f 100644
--- a/web/app/components/plugins/plugin-page/context-provider.tsx
+++ b/web/app/components/plugins/plugin-page/context-provider.tsx
@@ -3,13 +3,14 @@
import type { ReactNode } from 'react'
import type { PluginPageTab } from './context'
import type { FilterState } from './filter-management'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { parseAsStringEnum, useQueryState } from 'nuqs'
import {
useMemo,
useRef,
useState,
} from 'react'
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { PLUGIN_PAGE_TABS_MAP, usePluginPageTabs } from '../hooks'
import { PLUGIN_TYPE_SEARCH_MAP } from '../marketplace/constants'
import {
@@ -40,7 +41,10 @@ export const PluginPageContextProvider = ({
})
const [currentPluginID, setCurrentPluginID] = useState()
- const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
+ const { data: enable_marketplace } = useSuspenseQuery({
+ ...systemFeaturesQueryOptions(),
+ select: s => s.enable_marketplace,
+ })
const tabs = usePluginPageTabs()
const options = useMemo(() => {
return enable_marketplace ? tabs : tabs.filter(tab => tab.value !== PLUGIN_PAGE_TABS_MAP.marketplace)
diff --git a/web/app/components/plugins/plugin-page/empty/__tests__/index.spec.tsx b/web/app/components/plugins/plugin-page/empty/__tests__/index.spec.tsx
index 933814eca5..73a5dd379d 100644
--- a/web/app/components/plugins/plugin-page/empty/__tests__/index.spec.tsx
+++ b/web/app/components/plugins/plugin-page/empty/__tests__/index.spec.tsx
@@ -1,8 +1,10 @@
+import type { ReactElement } from 'react'
import type { FilterState } from '../../filter-management'
import type { SystemFeatures } from '@/types/feature'
-import { act, fireEvent, render, screen } from '@testing-library/react'
+import { act, fireEvent, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { defaultSystemFeatures, InstallationScope } from '@/types/feature'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
+import { InstallationScope } from '@/types/feature'
// ==================== Imports (after mocks) ====================
@@ -10,7 +12,9 @@ import Empty from '../index'
// ==================== Mock Setup ====================
-// Use vi.hoisted to define ALL mock state and functions
+// Use vi.hoisted to define ALL mock state and functions so the local render
+// helper below (and downstream `vi.mock` factories) can read from the same
+// shared object regardless of declaration order.
const {
mockSetActiveTab,
mockUseInstalledPluginList,
@@ -38,6 +42,9 @@ const {
}
})
+const render = (ui: ReactElement) =>
+ renderWithSystemFeatures(ui, { systemFeatures: mockState.systemFeatures })
+
// Mock plugin page context
vi.mock('../../context', () => ({
usePluginPageContext: (selector: (value: Record) => unknown) => {
@@ -49,18 +56,6 @@ vi.mock('../../context', () => ({
},
}))
-// Mock global public store (Zustand store)
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: (selector: (state: Record) => unknown) => {
- return selector({
- systemFeatures: {
- ...defaultSystemFeatures,
- ...mockState.systemFeatures,
- },
- })
- },
-}))
-
// Mock useInstalledPluginList hook
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => mockUseInstalledPluginList(),
diff --git a/web/app/components/plugins/plugin-page/empty/index.tsx b/web/app/components/plugins/plugin-page/empty/index.tsx
index e90918b4ab..7b9fab6f3e 100644
--- a/web/app/components/plugins/plugin-page/empty/index.tsx
+++ b/web/app/components/plugins/plugin-page/empty/index.tsx
@@ -1,5 +1,6 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
@@ -11,7 +12,7 @@ import { MagicBox } from '@/app/components/base/icons/src/vender/solid/mediaAndD
import InstallFromGitHub from '@/app/components/plugins/install-plugin/install-from-github'
import InstallFromLocalPackage from '@/app/components/plugins/install-plugin/install-from-local-package'
import { SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config'
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useInstalledPluginList } from '@/service/use-plugins'
import Line from '../../marketplace/empty/line'
import { usePluginPageContext } from '../context'
@@ -27,7 +28,14 @@ const Empty = () => {
const fileInputRef = useRef(null)
const [selectedAction, setSelectedAction] = useState(null)
const [selectedFile, setSelectedFile] = useState(null)
- const { enable_marketplace, plugin_installation_permission } = useGlobalPublicStore(s => s.systemFeatures)
+ const { data: enable_marketplace } = useSuspenseQuery({
+ ...systemFeaturesQueryOptions(),
+ select: s => s.enable_marketplace,
+ })
+ const { data: plugin_installation_permission } = useSuspenseQuery({
+ ...systemFeaturesQueryOptions(),
+ select: s => s.plugin_installation_permission,
+ })
const setActiveTab = usePluginPageContext(v => v.setActiveTab)
const handleFileChange = (event: React.ChangeEvent) => {
diff --git a/web/app/components/plugins/plugin-page/index.tsx b/web/app/components/plugins/plugin-page/index.tsx
index f80d37189a..61d5bfb387 100644
--- a/web/app/components/plugins/plugin-page/index.tsx
+++ b/web/app/components/plugins/plugin-page/index.tsx
@@ -9,6 +9,7 @@ import {
RiDragDropLine,
RiEqualizer2Line,
} from '@remixicon/react'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { useBoolean } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { useEffect, useMemo, useState } from 'react'
@@ -17,12 +18,12 @@ import TabSlider from '@/app/components/base/tab-slider'
import Tooltip from '@/app/components/base/tooltip'
import ReferenceSettingModal from '@/app/components/plugins/reference-setting-modal'
import { MARKETPLACE_API_PREFIX, SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config'
-import { useGlobalPublicStore } from '@/context/global-public-context'
import { useDocLink } from '@/context/i18n'
import useDocumentTitle from '@/hooks/use-document-title'
import { usePluginInstallation } from '@/hooks/use-query-params'
import Link from '@/next/link'
import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { sleep } from '@/utils'
import { PLUGIN_PAGE_TABS_MAP } from '../hooks'
import InstallFromLocalPackage from '../install-plugin/install-from-local-package'
@@ -121,7 +122,10 @@ const PluginPage = ({
const options = usePluginPageContext(v => v.options)
const activeTab = usePluginPageContext(v => v.activeTab)
const setActiveTab = usePluginPageContext(v => v.setActiveTab)
- const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
+ const { data: enable_marketplace } = useSuspenseQuery({
+ ...systemFeaturesQueryOptions(),
+ select: s => s.enable_marketplace,
+ })
const isPluginsTab = useMemo(() => activeTab === PLUGIN_PAGE_TABS_MAP.plugins, [activeTab])
const isExploringMarketplace = useMemo(() => {
diff --git a/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx b/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx
index dee3dc17bb..f74de31159 100644
--- a/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx
+++ b/web/app/components/plugins/plugin-page/install-plugin-dropdown.tsx
@@ -9,6 +9,7 @@ import {
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { RiAddLine, RiArrowDownSLine } from '@remixicon/react'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { noop } from 'es-toolkit/function'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -18,7 +19,7 @@ import { MagicBox } from '@/app/components/base/icons/src/vender/solid/mediaAndD
import InstallFromGitHub from '@/app/components/plugins/install-plugin/install-from-github'
import InstallFromLocalPackage from '@/app/components/plugins/install-plugin/install-from-local-package'
import { SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config'
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
type Props = {
onSwitchToMarketplaceTab: () => void
@@ -38,7 +39,14 @@ const InstallPluginDropdown = ({
const [isMenuOpen, setIsMenuOpen] = useState(false)
const [selectedAction, setSelectedAction] = useState(null)
const [selectedFile, setSelectedFile] = useState(null)
- const { enable_marketplace, plugin_installation_permission } = useGlobalPublicStore(s => s.systemFeatures)
+ const { data: enable_marketplace } = useSuspenseQuery({
+ ...systemFeaturesQueryOptions(),
+ select: s => s.enable_marketplace,
+ })
+ const { data: plugin_installation_permission } = useSuspenseQuery({
+ ...systemFeaturesQueryOptions(),
+ select: s => s.plugin_installation_permission,
+ })
const handleFileChange = (event: React.ChangeEvent) => {
const file = event.target.files?.[0]
diff --git a/web/app/components/plugins/plugin-page/use-reference-setting.ts b/web/app/components/plugins/plugin-page/use-reference-setting.ts
index b688bdc11e..7c62c1879a 100644
--- a/web/app/components/plugins/plugin-page/use-reference-setting.ts
+++ b/web/app/components/plugins/plugin-page/use-reference-setting.ts
@@ -1,8 +1,9 @@
import { toast } from '@langgenius/dify-ui/toast'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useInvalidateReferenceSettings, useMutationReferenceSettings, useReferenceSettings } from '@/service/use-plugins'
import { PermissionType } from '../types'
@@ -45,7 +46,10 @@ const useReferenceSetting = () => {
}
export const useCanInstallPluginFromMarketplace = () => {
- const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
+ const { data: enable_marketplace } = useSuspenseQuery({
+ ...systemFeaturesQueryOptions(),
+ select: s => s.enable_marketplace,
+ })
const { canManagement } = useReferenceSetting()
const canInstallPluginFromMarketplace = useMemo(() => {
diff --git a/web/app/components/plugins/reference-setting-modal/__tests__/index.spec.tsx b/web/app/components/plugins/reference-setting-modal/__tests__/index.spec.tsx
index 26eeb7fe03..710fce397c 100644
--- a/web/app/components/plugins/reference-setting-modal/__tests__/index.spec.tsx
+++ b/web/app/components/plugins/reference-setting-modal/__tests__/index.spec.tsx
@@ -1,19 +1,18 @@
+import type { ReactElement } from 'react'
import type { AutoUpdateConfig } from '../auto-update-setting/types'
import type { Permissions, ReferenceSetting } from '@/app/components/plugins/types'
-import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { fireEvent, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { PermissionType } from '@/app/components/plugins/types'
import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from '../auto-update-setting/types'
import ReferenceSettingModal from '../index'
-// Mock global public store
const mockSystemFeatures = { enable_marketplace: true }
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: (selector: (s: { systemFeatures: typeof mockSystemFeatures }) => typeof mockSystemFeatures) => {
- return selector({ systemFeatures: mockSystemFeatures })
- },
-}))
+
+const render = (ui: ReactElement) =>
+ renderWithSystemFeatures(ui, { systemFeatures: mockSystemFeatures })
// Mock Modal component
vi.mock('@/app/components/base/modal', () => ({
diff --git a/web/app/components/plugins/reference-setting-modal/index.tsx b/web/app/components/plugins/reference-setting-modal/index.tsx
index 061777e38b..b93713bdd8 100644
--- a/web/app/components/plugins/reference-setting-modal/index.tsx
+++ b/web/app/components/plugins/reference-setting-modal/index.tsx
@@ -3,13 +3,14 @@ import type { FC } from 'react'
import type { AutoUpdateConfig } from './auto-update-setting/types'
import type { Permissions, ReferenceSetting } from '@/app/components/plugins/types'
import { Button } from '@langgenius/dify-ui/button'
+import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
import { PermissionType } from '@/app/components/plugins/types'
import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import AutoUpdateSetting from './auto-update-setting'
import { defaultValue as autoUpdateDefaultValue } from './auto-update-setting/config'
import Label from './label'
@@ -30,7 +31,10 @@ const PluginSettingModal: FC = ({
const { auto_upgrade: autoUpdateConfig, permission: privilege } = payload || {}
const [tempPrivilege, setTempPrivilege] = useState(privilege)
const [tempAutoUpdateConfig, setTempAutoUpdateConfig] = useState(autoUpdateConfig || autoUpdateDefaultValue)
- const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
+ const { data: enable_marketplace } = useSuspenseQuery({
+ ...systemFeaturesQueryOptions(),
+ select: s => s.enable_marketplace,
+ })
const handlePrivilegeChange = useCallback((key: string) => {
return (value: PermissionType) => {
setTempPrivilege({
diff --git a/web/app/components/share/text-generation/hooks/__tests__/use-text-generation-app-state.spec.ts b/web/app/components/share/text-generation/hooks/__tests__/use-text-generation-app-state.spec.ts
index 6fd1b2a158..900a30bb0e 100644
--- a/web/app/components/share/text-generation/hooks/__tests__/use-text-generation-app-state.spec.ts
+++ b/web/app/components/share/text-generation/hooks/__tests__/use-text-generation-app-state.spec.ts
@@ -1,7 +1,13 @@
-import { act, renderHook, waitFor } from '@testing-library/react'
+import { act, waitFor } from '@testing-library/react'
+import { renderHookWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { AppSourceType } from '@/service/share'
import { useTextGenerationAppState } from '../use-text-generation-app-state'
+const renderHook = (callback: (props: Props) => Result) =>
+ renderHookWithSystemFeatures(callback, {
+ systemFeatures: { branding: { enabled: false, workspace_logo: '' } },
+ })
+
const {
changeLanguageMock,
fetchSavedMessageMock,
@@ -60,13 +66,6 @@ vi.mock('@/service/share', async () => {
}
})
-const mockSystemFeatures = {
- branding: {
- enabled: false,
- workspace_logo: null,
- },
-}
-
const defaultAppInfo = {
app_id: 'app-123',
site: {
@@ -169,11 +168,6 @@ const resetMockWebAppState = () => {
mockWebAppState.webAppAccessMode = 'public'
}
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: (selector: (state: { systemFeatures: typeof mockSystemFeatures }) => unknown) =>
- selector({ systemFeatures: mockSystemFeatures }),
-}))
-
vi.mock('@/context/web-app-context', () => ({
useWebAppStore: (selector: (state: typeof mockWebAppState) => unknown) => selector(mockWebAppState),
}))
diff --git a/web/app/components/share/text-generation/hooks/use-text-generation-app-state.ts b/web/app/components/share/text-generation/hooks/use-text-generation-app-state.ts
index 47cb3d7993..12e0f22d16 100644
--- a/web/app/components/share/text-generation/hooks/use-text-generation-app-state.ts
+++ b/web/app/components/share/text-generation/hooks/use-text-generation-app-state.ts
@@ -3,15 +3,16 @@ import type { MoreLikeThisConfig, PromptConfig, SavedMessage, TextToSpeechConfig
import type { SiteInfo } from '@/models/share'
import type { VisionSettings } from '@/types/app'
import { toast } from '@langgenius/dify-ui/toast'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getRawInputsFromUrlParams } from '@/app/components/base/chat/utils'
-import { useGlobalPublicStore } from '@/context/global-public-context'
import { useWebAppStore } from '@/context/web-app-context'
import { useAppFavicon } from '@/hooks/use-app-favicon'
import useDocumentTitle from '@/hooks/use-document-title'
import { changeLanguage } from '@/i18n-config/client'
import { AppSourceType, fetchSavedMessage as doFetchSavedMessage, removeMessage, saveMessage } from '@/service/share'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { Resolution, TransferMethod } from '@/types/app'
import { userInputsFormToPromptVariables } from '@/utils/model-config'
@@ -72,7 +73,7 @@ const coerceWorkflowUrlDefault = (
export const useTextGenerationAppState = ({ isInstalledApp, isWorkflow }: UseTextGenerationAppStateOptions) => {
const { t } = useTranslation()
const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp
- const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const appData = useWebAppStore(s => s.appInfo)
const appParams = useWebAppStore(s => s.appParams)
const accessMode = useWebAppStore(s => s.webAppAccessMode)
diff --git a/web/app/components/snippets/components/__tests__/snippet-layout.spec.tsx b/web/app/components/snippets/components/__tests__/snippet-layout.spec.tsx
index 3202c3dc43..7100b54142 100644
--- a/web/app/components/snippets/components/__tests__/snippet-layout.spec.tsx
+++ b/web/app/components/snippets/components/__tests__/snippet-layout.spec.tsx
@@ -40,13 +40,21 @@ vi.mock('@/app/components/app-sidebar', () => ({
}))
vi.mock('@/app/components/app-sidebar/nav-link', () => ({
- default: ({ name, href, active }: { name: string, href: string, active: boolean }) => (
-
- {name}
-
+ default: ({ name, href, active, disabled }: { name: string, href: string, active: boolean, disabled?: boolean }) => (
+ disabled
+ ? (
+
+ {name}
+
+ )
+ : (
+
+ {name}
+
+ )
),
}))
@@ -63,6 +71,7 @@ const createSnippet = (overrides: Partial = {}): SnippetDetail =>
usage: '42',
icon: 'emoji',
iconBackground: '#ffffff',
+ is_published: true,
...overrides,
})
@@ -104,5 +113,20 @@ describe('SnippetLayout', () => {
expect(screen.getByRole('link', { name: 'snippet.sectionEvaluation' })).toHaveAttribute('href', '/snippets/snippet-1/evaluation')
expect(screen.getByRole('link', { name: 'snippet.sectionEvaluation' })).toHaveAttribute('aria-current', 'page')
})
+
+ it('should disable the evaluation menu when the snippet is unpublished', () => {
+ render(
+
+ content
+ ,
+ )
+
+ expect(screen.getByRole('button', { name: 'snippet.sectionEvaluation' })).toBeDisabled()
+ expect(screen.queryByRole('link', { name: 'snippet.sectionEvaluation' })).not.toBeInTheDocument()
+ })
})
})
diff --git a/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx b/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx
index ed0ce3987d..05ed901d0b 100644
--- a/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx
+++ b/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx
@@ -1,6 +1,7 @@
import type { WorkflowProps } from '@/app/components/workflow'
import type { SnippetDetailPayload, SnippetInputField } from '@/models/snippet'
-import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { fireEvent, screen, waitFor } from '@testing-library/react'
+import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { PipelineInputVarType } from '@/models/pipeline'
import SnippetMain from '../snippet-main'
@@ -191,7 +192,7 @@ const payload: SnippetDetailPayload = {
}
const renderSnippetMain = () => {
- return render(
+ return renderWorkflowComponent(
{
beforeEach(() => {
vi.clearAllMocks()
mockSyncInputFieldsDraft.mockResolvedValue(undefined)
- mockPublishSnippetMutateAsync.mockResolvedValue(undefined)
+ mockPublishSnippetMutateAsync.mockResolvedValue({ created_at: 1_744_000_000 })
capturedHooksStore = undefined
snippetDetailStoreState = {
editingField: null,
diff --git a/web/app/components/snippets/components/hooks/__tests__/use-snippet-input-field-actions.spec.ts b/web/app/components/snippets/components/hooks/__tests__/use-snippet-input-field-actions.spec.ts
index 8b17a6e723..aa0a3b994e 100644
--- a/web/app/components/snippets/components/hooks/__tests__/use-snippet-input-field-actions.spec.ts
+++ b/web/app/components/snippets/components/hooks/__tests__/use-snippet-input-field-actions.spec.ts
@@ -1,6 +1,6 @@
import type { SnippetInputField } from '@/models/snippet'
-import { act, renderHook } from '@testing-library/react'
import { toast } from '@langgenius/dify-ui/toast'
+import { act, renderHook } from '@testing-library/react'
import { PipelineInputVarType } from '@/models/pipeline'
import { useSnippetInputFieldActions } from '../use-snippet-input-field-actions'
diff --git a/web/app/components/snippets/components/hooks/__tests__/use-snippet-publish.spec.ts b/web/app/components/snippets/components/hooks/__tests__/use-snippet-publish.spec.ts
index fc2d484e47..2566ecf1fd 100644
--- a/web/app/components/snippets/components/hooks/__tests__/use-snippet-publish.spec.ts
+++ b/web/app/components/snippets/components/hooks/__tests__/use-snippet-publish.spec.ts
@@ -1,11 +1,12 @@
-import { act, renderHook, waitFor } from '@testing-library/react'
import { toast } from '@langgenius/dify-ui/toast'
+import { act, renderHook, waitFor } from '@testing-library/react'
import { useSnippetPublish } from '../use-snippet-publish'
const mockMutateAsync = vi.fn()
const mockSetPublishMenuOpen = vi.fn()
const mockUseKeyPress = vi.fn()
const mockSetPublishedAt = vi.fn()
+const mockSetQueryData = vi.fn()
let isPublishMenuOpen = false
let isPending = false
@@ -22,6 +23,12 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
},
}))
+vi.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({
+ setQueryData: mockSetQueryData,
+ }),
+}))
+
vi.mock('@/service/use-snippet-workflows', () => ({
usePublishSnippetWorkflowMutation: () => ({
mutateAsync: mockMutateAsync,
@@ -72,6 +79,9 @@ describe('useSnippetPublish', () => {
expect(mockMutateAsync).toHaveBeenCalledWith({
params: { snippetId: 'snippet-1' },
})
+ expect(mockSetQueryData).toHaveBeenCalledTimes(1)
+ const updateSnippetDetail = mockSetQueryData.mock.calls[0]![1] as (old: { is_published: boolean }) => { is_published: boolean }
+ expect(updateSnippetDetail({ is_published: false })).toEqual({ is_published: true })
expect(mockSetPublishedAt).toHaveBeenCalledWith(1_712_345_678)
expect(mockSetPublishMenuOpen).toHaveBeenCalledWith(false)
expect(toast.success).toHaveBeenCalledWith('snippet.publishSuccess')
diff --git a/web/app/components/snippets/components/hooks/use-snippet-input-field-actions.ts b/web/app/components/snippets/components/hooks/use-snippet-input-field-actions.ts
index 47859d95f6..6639806f63 100644
--- a/web/app/components/snippets/components/hooks/use-snippet-input-field-actions.ts
+++ b/web/app/components/snippets/components/hooks/use-snippet-input-field-actions.ts
@@ -1,8 +1,8 @@
import type { SnippetInputField } from '@/models/snippet'
+import { toast } from '@langgenius/dify-ui/toast'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
-import { toast } from '@langgenius/dify-ui/toast'
import { useNodesSyncDraft } from '../../hooks/use-nodes-sync-draft'
import { useSnippetDetailStore } from '../../store'
diff --git a/web/app/components/snippets/components/hooks/use-snippet-publish.ts b/web/app/components/snippets/components/hooks/use-snippet-publish.ts
index e29af2a462..bb803ccbac 100644
--- a/web/app/components/snippets/components/hooks/use-snippet-publish.ts
+++ b/web/app/components/snippets/components/hooks/use-snippet-publish.ts
@@ -1,10 +1,13 @@
+import type { Snippet as SnippetContract } from '@/types/snippet'
+import { toast } from '@langgenius/dify-ui/toast'
+import { useQueryClient } from '@tanstack/react-query'
import { useKeyPress } from 'ahooks'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
-import { toast } from '@langgenius/dify-ui/toast'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils'
+import { consoleQuery } from '@/service/client'
import { usePublishSnippetWorkflowMutation } from '@/service/use-snippet-workflows'
import { useSnippetDetailStore } from '../../store'
@@ -17,6 +20,7 @@ export const useSnippetPublish = ({
}: UseSnippetPublishOptions) => {
const { t } = useTranslation('snippet')
const workflowStore = useWorkflowStore()
+ const queryClient = useQueryClient()
const publishSnippetMutation = usePublishSnippetWorkflowMutation(snippetId)
const {
isPublishMenuOpen,
@@ -31,6 +35,14 @@ export const useSnippetPublish = ({
const publishedWorkflow = await publishSnippetMutation.mutateAsync({
params: { snippetId },
})
+ queryClient.setQueryData(
+ consoleQuery.snippets.detail.queryKey({
+ input: {
+ params: { snippetId },
+ },
+ }),
+ old => old ? { ...old, is_published: true } : old,
+ )
workflowStore.getState().setPublishedAt(publishedWorkflow.created_at)
setPublishMenuOpen(false)
toast.success(t('publishSuccess'))
@@ -38,7 +50,7 @@ export const useSnippetPublish = ({
catch (error) {
toast.error(error instanceof Error ? error.message : t('publishFailed'))
}
- }, [publishSnippetMutation, setPublishMenuOpen, snippetId, t, workflowStore])
+ }, [publishSnippetMutation, queryClient, setPublishMenuOpen, snippetId, t, workflowStore])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (event) => {
if (publishSnippetMutation.isPending)
diff --git a/web/app/components/snippets/components/input-field-form/index.tsx b/web/app/components/snippets/components/input-field-form/index.tsx
index a706e4b3d7..f62dbe933e 100644
--- a/web/app/components/snippets/components/input-field-form/index.tsx
+++ b/web/app/components/snippets/components/input-field-form/index.tsx
@@ -1,12 +1,12 @@
import type { FormData, InputFieldFormProps } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/types'
import type { MoreInfo } from '@/app/components/workflow/types'
+import { Button } from '@langgenius/dify-ui/button'
+import { toast } from '@langgenius/dify-ui/toast'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { useFileSizeLimit } from '@/app/components/base/file-uploader/hooks'
import { useAppForm } from '@/app/components/base/form'
-import { Button } from '@langgenius/dify-ui/button'
-import { toast } from '@langgenius/dify-ui/toast'
import HiddenFields from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/hidden-fields'
import ShowAllSettings from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/show-all-settings'
import { ChangeType } from '@/app/components/workflow/types'
@@ -28,7 +28,7 @@ const SnippetInputFieldForm = ({ initialData, supportFile = false, onCancel, onS
if (!result.success) {
const issues = result.error.issues
const firstIssue = issues[0]
- const errorMessage = `"${firstIssue.path.join('.')}" ${firstIssue.message}`
+ const errorMessage = `"${firstIssue!.path.join('.')}" ${firstIssue!.message}`
toast.error(errorMessage)
return errorMessage
}
diff --git a/web/app/components/snippets/components/panel/index.tsx b/web/app/components/snippets/components/panel/index.tsx
index 90d7c38014..632c6e6239 100644
--- a/web/app/components/snippets/components/panel/index.tsx
+++ b/web/app/components/snippets/components/panel/index.tsx
@@ -2,9 +2,9 @@
import type { SortableItem } from '@/app/components/rag-pipeline/components/panel/input-field/field-list/types'
import type { SnippetInputField } from '@/models/snippet'
+import { Button } from '@langgenius/dify-ui/button'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
-import { Button } from '@langgenius/dify-ui/button'
import FieldListContainer from '@/app/components/rag-pipeline/components/panel/input-field/field-list/field-list-container'
type SnippetInputFieldPanelProps = {
@@ -45,12 +45,12 @@ const SnippetInputFieldPanel = ({
return (
-
+
-
+
{t('panelTitle')}
-
+
{t('panelDescription')}
diff --git a/web/app/components/snippets/components/publish-menu.tsx b/web/app/components/snippets/components/publish-menu.tsx
index de1bb63bb6..0a2ad39bb0 100644
--- a/web/app/components/snippets/components/publish-menu.tsx
+++ b/web/app/components/snippets/components/publish-menu.tsx
@@ -1,8 +1,8 @@
'use client'
import type { SnippetDetailUIModel } from '@/models/snippet'
-import { useTranslation } from 'react-i18next'
import { Button } from '@langgenius/dify-ui/button'
+import { useTranslation } from 'react-i18next'
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
diff --git a/web/app/components/snippets/components/snippet-create-card.tsx b/web/app/components/snippets/components/snippet-create-card.tsx
index b7b4e342a6..cea7ebcafb 100644
--- a/web/app/components/snippets/components/snippet-create-card.tsx
+++ b/web/app/components/snippets/components/snippet-create-card.tsx
@@ -1,9 +1,9 @@
'use client'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
+import { toast } from '@langgenius/dify-ui/toast'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { toast } from '@langgenius/dify-ui/toast'
import CreateSnippetDialog from '@/app/components/workflow/create-snippet-dialog'
import { useRouter } from '@/next/navigation'
import {
@@ -62,22 +62,22 @@ const SnippetCreateCard = () => {
<>
-
{t('create')}
+
{t('create')}
-
+
{t('createFromBlank')}
-
+
{t('importDSL', { ns: 'app' })}
diff --git a/web/app/components/snippets/components/snippet-header/publisher.tsx b/web/app/components/snippets/components/snippet-header/publisher.tsx
index b100d98ef4..f7bc2972ee 100644
--- a/web/app/components/snippets/components/snippet-header/publisher.tsx
+++ b/web/app/components/snippets/components/snippet-header/publisher.tsx
@@ -1,13 +1,13 @@
'use client'
import type { SnippetDetailUIModel } from '@/models/snippet'
-import { memo } from 'react'
-import { useTranslation } from 'react-i18next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
+import { memo } from 'react'
+import { useTranslation } from 'react-i18next'
import PublishMenu from '../publish-menu'
type PublisherProps = {
diff --git a/web/app/components/snippets/components/snippet-import-dsl-dialog.tsx b/web/app/components/snippets/components/snippet-import-dsl-dialog.tsx
index 861d7ee304..be23700e62 100644
--- a/web/app/components/snippets/components/snippet-import-dsl-dialog.tsx
+++ b/web/app/components/snippets/components/snippet-import-dsl-dialog.tsx
@@ -1,13 +1,13 @@
'use client'
+import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
+import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
+import { toast } from '@langgenius/dify-ui/toast'
import { useDebounceFn, useKeyPress } from 'ahooks'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
import Input from '@/app/components/base/input'
-import { Button } from '@langgenius/dify-ui/button'
-import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
-import { toast } from '@langgenius/dify-ui/toast'
import {
DSLImportMode,
DSLImportStatus,
@@ -169,7 +169,7 @@ const SnippetImportDSLDialog = ({
-
+
{[
{ key: SnippetImportDSLTab.FromFile, label: t('importFromDSLFile', { ns: 'app' }) },
{ key: SnippetImportDSLTab.FromURL, label: t('importFromDSLUrl', { ns: 'app' }) },
@@ -201,7 +201,7 @@ const SnippetImportDSLDialog = ({
)}
{currentTab === SnippetImportDSLTab.FromURL && (
-
DSL URL
+
DSL URL
{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}
-
+
{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}
{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}
diff --git a/web/app/components/snippets/components/snippet-layout.tsx b/web/app/components/snippets/components/snippet-layout.tsx
index 2d03f3a065..b9cfaccfb7 100644
--- a/web/app/components/snippets/components/snippet-layout.tsx
+++ b/web/app/components/snippets/components/snippet-layout.tsx
@@ -1,14 +1,9 @@
'use client'
-import type { ReactNode } from 'react'
+import type { FC, ReactNode } from 'react'
import type { NavIcon } from '@/app/components/app-sidebar/nav-link'
import type { SnippetDetail, SnippetSection } from '@/models/snippet'
-import {
- RiFlaskFill,
- RiFlaskLine,
- RiTerminalWindowFill,
- RiTerminalWindowLine,
-} from '@remixicon/react'
+import { cn } from '@langgenius/dify-ui/cn'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import AppSideBar from '@/app/components/app-sidebar'
@@ -25,14 +20,34 @@ type SnippetLayoutProps = {
snippetId: string
}
+const SidebarCssIcon: FC<{ iconClassName: string, className?: string }> = ({ iconClassName, className }) => {
+ return
+}
+
+const OrchestrateIcon = ({ className }: { className?: string }) => {
+ return
+}
+
+const OrchestrateSelectedIcon = ({ className }: { className?: string }) => {
+ return
+}
+
+const EvaluationIcon = ({ className }: { className?: string }) => {
+ return
+}
+
+const EvaluationSelectedIcon = ({ className }: { className?: string }) => {
+ return
+}
+
const ORCHESTRATE_ICONS: { normal: NavIcon, selected: NavIcon } = {
- normal: RiTerminalWindowLine,
- selected: RiTerminalWindowFill,
+ normal: OrchestrateIcon,
+ selected: OrchestrateSelectedIcon,
}
const EVALUATION_ICONS: { normal: NavIcon, selected: NavIcon } = {
- normal: RiFlaskLine,
- selected: RiFlaskFill,
+ normal: EvaluationIcon,
+ selected: EvaluationSelectedIcon,
}
const SnippetLayout = ({
@@ -74,6 +89,7 @@ const SnippetLayout = ({
iconMap={EVALUATION_ICONS}
href={`/snippets/${snippetId}/evaluation`}
active={section === 'evaluation'}
+ disabled={!snippet.is_published}
/>
>
)}
diff --git a/web/app/components/snippets/components/snippet-run-panel.tsx b/web/app/components/snippets/components/snippet-run-panel.tsx
index 952df4c821..fb3a27737d 100644
--- a/web/app/components/snippets/components/snippet-run-panel.tsx
+++ b/web/app/components/snippets/components/snippet-run-panel.tsx
@@ -3,6 +3,8 @@
import type { InputForm } from '@/app/components/base/chat/chat/type'
import type { InputVar as WorkflowInputVar } from '@/app/components/workflow/types'
import type { SnippetInputField } from '@/models/snippet'
+import { Button } from '@langgenius/dify-ui/button'
+import { toast } from '@langgenius/dify-ui/toast'
import copy from 'copy-to-clipboard'
import {
memo,
@@ -12,11 +14,9 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
-import { Button } from '@langgenius/dify-ui/button'
import { useCheckInputsForms } from '@/app/components/base/chat/chat/check-input-forms-hooks'
import { getProcessedInputs } from '@/app/components/base/chat/chat/utils'
import Loading from '@/app/components/base/loading'
-import { toast } from '@langgenius/dify-ui/toast'
import {
useWorkflowInteractions,
useWorkflowRun,
@@ -157,7 +157,7 @@ const SnippetRunPanel = ({
style={{ width: `${panelWidth}px` }}
>
@@ -170,26 +170,26 @@ const SnippetRunPanel = ({
{hasInputTab && (
setSelectedTab('INPUT')}
>
{t('input', { ns: 'runLog' })}
)}
workflowRunningData && setSelectedTab('RESULT')}
>
{t('result', { ns: 'runLog' })}
workflowRunningData && setSelectedTab('DETAIL')}
>
{t('detail', { ns: 'runLog' })}
workflowRunningData && setSelectedTab('TRACING')}
>
{t('tracing', { ns: 'runLog' })}
@@ -198,7 +198,7 @@ const SnippetRunPanel = ({
{currentTab === 'INPUT' && hasInputTab && (
<>
-
+
{previewFields.map((field, index) => (
= () => {
- const { isPending } = useUserProfile()
-
- if (isPending) {
- return (
-
-
-
- )
- }
-
- return null
-}
-export default React.memo(Splash)
diff --git a/web/app/components/tools/__tests__/provider-list.spec.tsx b/web/app/components/tools/__tests__/provider-list.spec.tsx
index a5cb4821bb..6661c26083 100644
--- a/web/app/components/tools/__tests__/provider-list.spec.tsx
+++ b/web/app/components/tools/__tests__/provider-list.spec.tsx
@@ -1,5 +1,7 @@
+import type { ReactNode } from 'react'
import { cleanup, fireEvent, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
import { renderWithNuqs } from '@/test/nuqs-testing'
import { ToolTypeEnum } from '../../workflow/block-selector/types'
import ProviderList from '../provider-list'
@@ -14,10 +16,6 @@ vi.mock('@/app/components/plugins/hooks', () => ({
}))
let mockEnableMarketplace = false
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: (selector: (s: Record
) => unknown) =>
- selector({ systemFeatures: { enable_marketplace: mockEnableMarketplace } }),
-}))
const createDefaultCollections = () => [
{
@@ -206,8 +204,14 @@ describe('getToolType', () => {
})
const renderProviderList = (searchParams?: Record) => {
+ const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
+ systemFeatures: { enable_marketplace: mockEnableMarketplace },
+ })
+ const Wrapped = ({ children }: { children: ReactNode }) => (
+ {children}
+ )
return renderWithNuqs(
- ,
+ ,
{ searchParams },
)
}
diff --git a/web/app/components/tools/mcp/hooks/use-mcp-service-card.ts b/web/app/components/tools/mcp/hooks/use-mcp-service-card.ts
index 8547b7f7ba..5ea6a237fc 100644
--- a/web/app/components/tools/mcp/hooks/use-mcp-service-card.ts
+++ b/web/app/components/tools/mcp/hooks/use-mcp-service-card.ts
@@ -69,7 +69,7 @@ export const useMCPServiceCardState = (
const serverPublished = !!id
const serverActivated = status === 'active'
const serverURL = serverPublished
- ? `${appInfo.api_base_url.replace('/v1', '')}/mcp/server/${server_code}/mcp`
+ ? `${appInfo.api_base_url.replace(/\/v1$/, '')}/mcp/server/${server_code}/mcp`
: '***********'
// App state checks
diff --git a/web/app/components/tools/provider-list.tsx b/web/app/components/tools/provider-list.tsx
index 7d400b7cf7..74fc47a833 100644
--- a/web/app/components/tools/provider-list.tsx
+++ b/web/app/components/tools/provider-list.tsx
@@ -1,6 +1,7 @@
'use client'
import type { Collection } from './types'
import { cn } from '@langgenius/dify-ui/cn'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { parseAsStringLiteral, useQueryState } from 'nuqs'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -15,7 +16,7 @@ import LabelFilter from '@/app/components/tools/labels/filter'
import CustomCreateCard from '@/app/components/tools/provider/custom-create-card'
import ProviderDetail from '@/app/components/tools/provider/detail'
import WorkflowToolEmpty from '@/app/components/tools/provider/empty'
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useCheckInstalled, useInvalidateInstalledPluginList } from '@/service/use-plugins'
import { useAllToolProviders } from '@/service/use-tools'
import Marketplace from './marketplace'
@@ -39,7 +40,10 @@ const ProviderList = () => {
// searchParams.get('category') === 'workflow'
const { t } = useTranslation()
const { getTagLabel } = useTags()
- const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
+ const { data: enable_marketplace } = useSuspenseQuery({
+ ...systemFeaturesQueryOptions(),
+ select: s => s.enable_marketplace,
+ })
const containerRef = useRef(null)
const [activeTab, setActiveTab] = useQueryState('category', parseAsToolProviderCategory)
diff --git a/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx b/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx
index 41e47967b2..5f9815a72f 100644
--- a/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx
+++ b/web/app/components/workflow-app/components/workflow-header/__tests__/features-trigger.spec.tsx
@@ -127,6 +127,9 @@ vi.mock('@/app/components/app/app-publisher', () => ({
{ Promise.resolve(props.onPublish?.({ title: 'Test title', releaseNotes: 'Test notes' })).catch(() => undefined) }}>
publisher-publish-with-params
+ { Promise.resolve(props.onPublish?.({ url: '/apps/app-id/workflows/publish/evaluation', title: 'Evaluation title', releaseNotes: 'Evaluation notes' })).catch(() => undefined) }}>
+ publisher-publish-evaluation
+
)
},
@@ -457,6 +460,24 @@ describe('FeaturesTrigger', () => {
})
})
+ it('should respect the publish url passed by the publisher', async () => {
+ // Arrange
+ const user = userEvent.setup()
+ renderWithToast(
)
+
+ // Act
+ await user.click(screen.getByRole('button', { name: 'publisher-publish-evaluation' }))
+
+ // Assert
+ await waitFor(() => {
+ expect(mockPublishWorkflow).toHaveBeenCalledWith({
+ url: '/apps/app-id/workflows/publish/evaluation',
+ title: 'Evaluation title',
+ releaseNotes: 'Evaluation notes',
+ })
+ })
+ })
+
it('should skip success side effects when publish mutation returns no workflow version', async () => {
// Arrange
const user = userEvent.setup()
diff --git a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx
index d1aafa5c43..a441532a7f 100644
--- a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx
+++ b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx
@@ -158,7 +158,7 @@ const FeaturesTrigger = () => {
// Then perform the detailed validation
if (await handleCheckBeforePublish()) {
const res = await publishWorkflow({
- url: `/apps/${appID}/workflows/publish`,
+ url: publishParams?.url || `/apps/${appID}/workflows/publish`,
title: publishParams?.title || '',
releaseNotes: publishParams?.releaseNotes || '',
})
diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/index.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/index.tsx
index cbb875a758..c72a515925 100644
--- a/web/app/components/workflow-app/components/workflow-onboarding-modal/index.tsx
+++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/index.tsx
@@ -45,7 +45,7 @@ const WorkflowOnboardingModal: FC
= ({
- {/* TODO: reduce z-1002 to match base/ui primitives after legacy overlay migration completes */}
+ {/* TODO: reduce z-1002 to match @langgenius/dify-ui primitives after legacy overlay migration completes */}
{t('onboarding.escTip.press', { ns: 'workflow' })}
diff --git a/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.tsx b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.tsx
index dfb36f5534..e5d148f6b5 100644
--- a/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.tsx
+++ b/web/app/components/workflow-app/components/workflow-onboarding-modal/start-node-selection-panel.tsx
@@ -21,7 +21,7 @@ const StartNodeSelectionPanel: FC
= ({
onSelectTrigger,
}) => {
const { t } = useTranslation()
- const appType = useAppStore(s => s.appDetail?.type)
+ const appType = useAppStore(s => s.appDetail?.workflow_kind)
const [showTriggerSelector, setShowTriggerSelector] = useState(false)
const isEvaluationWorkflowType = isEvaluationWorkflow(appType)
@@ -62,7 +62,7 @@ const StartNodeSelectionPanel: FC = ({
trigger={() => (
+
)}
diff --git a/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts
index 1a8a7d3a59..d9e9cbdb8d 100644
--- a/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts
+++ b/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts
@@ -1,6 +1,7 @@
-import { act, renderHook } from '@testing-library/react'
+import { act } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { renderHookWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { useNodesSyncDraft } from '../use-nodes-sync-draft'
const mockGetNodes = vi.fn()
@@ -69,15 +70,6 @@ vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', ()
},
}))
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_collaboration_mode: boolean } }) => unknown) =>
- selector({
- systemFeatures: {
- enable_collaboration_mode: isCollaborationEnabled,
- },
- }),
-}))
-
vi.mock('@/app/components/workflow/hooks/use-serial-async-callback', () => ({
useSerialAsyncCallback: (fn: (...args: unknown[]) => Promise, checkFn: () => boolean) =>
(...args: unknown[]) => {
@@ -91,14 +83,28 @@ vi.mock('@/service/workflow', () => ({
syncWorkflowDraft: (p: unknown) => mockSyncWorkflowDraft(p),
}))
-vi.mock('@/service/fetch', () => ({ postWithKeepalive: (...args: unknown[]) => mockPostWithKeepalive(...args) }))
-vi.mock('@/config', () => ({ API_PREFIX: '/api' }))
+vi.mock('@/service/fetch', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ postWithKeepalive: (...args: unknown[]) => mockPostWithKeepalive(...args),
+ }
+})
+vi.mock('@/config', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, API_PREFIX: '/api' }
+})
const mockHandleRefreshWorkflowDraft = vi.fn()
vi.mock('@/app/components/workflow-app/hooks', () => ({
useWorkflowRefreshDraft: () => ({ handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft }),
}))
+const renderUseNodesSyncDraft = () =>
+ renderHookWithSystemFeatures(() => useNodesSyncDraft(), {
+ systemFeatures: { enable_collaboration_mode: isCollaborationEnabled },
+ })
+
describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -139,7 +145,7 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
const error = { json: vi.fn().mockResolvedValue({ code: 'draft_workflow_not_sync' }), bodyUsed: false }
mockSyncWorkflowDraft.mockRejectedValue(error)
- const { result } = renderHook(() => useNodesSyncDraft())
+ const { result } = renderUseNodesSyncDraft()
await act(async () => {
await result.current.doSyncWorkflowDraft(false)
})
@@ -152,7 +158,7 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
const error = { json: vi.fn().mockResolvedValue({ code: 'draft_workflow_not_sync' }), bodyUsed: false }
mockSyncWorkflowDraft.mockRejectedValue(error)
- const { result } = renderHook(() => useNodesSyncDraft())
+ const { result } = renderUseNodesSyncDraft()
await act(async () => {
await result.current.doSyncWorkflowDraft(true)
})
@@ -165,7 +171,7 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
const error = { json: vi.fn().mockResolvedValue({ code: 'other_error' }), bodyUsed: false }
mockSyncWorkflowDraft.mockRejectedValue(error)
- const { result } = renderHook(() => useNodesSyncDraft())
+ const { result } = renderUseNodesSyncDraft()
await act(async () => {
await result.current.doSyncWorkflowDraft(false)
})
@@ -175,7 +181,7 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
})
it('should not include source_workflow_id in draft sync payloads', async () => {
- const { result } = renderHook(() => useNodesSyncDraft())
+ const { result } = renderUseNodesSyncDraft()
await act(async () => {
await result.current.doSyncWorkflowDraft(false)
@@ -225,7 +231,7 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
onSettled: vi.fn(),
}
- const { result } = renderHook(() => useNodesSyncDraft())
+ const { result } = renderUseNodesSyncDraft()
await act(async () => {
await result.current.doSyncWorkflowDraft(false, callbacks)
@@ -272,7 +278,7 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
conversationVariables: [{ id: 'conversation-1' }],
}
- const { result } = renderHook(() => useNodesSyncDraft())
+ const { result } = renderUseNodesSyncDraft()
act(() => {
result.current.syncWorkflowDraftWhenPageClose()
@@ -296,7 +302,7 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
onSettled: vi.fn(),
}
- const { result } = renderHook(() => useNodesSyncDraft())
+ const { result } = renderUseNodesSyncDraft()
await act(async () => {
await result.current.doSyncWorkflowDraft(false, callbacks)
@@ -314,7 +320,7 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
mockCollaborationIsConnected.mockReturnValue(true)
mockCollaborationGetIsLeader.mockReturnValue(false)
- const { result } = renderHook(() => useNodesSyncDraft())
+ const { result } = renderUseNodesSyncDraft()
act(() => {
result.current.syncWorkflowDraftWhenPageClose()
diff --git a/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts b/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts
index 33edaa2ed5..b11be29e9a 100644
--- a/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts
+++ b/web/app/components/workflow-app/hooks/use-available-nodes-meta-data.ts
@@ -18,7 +18,7 @@ import { useIsChatMode } from './use-is-chat-mode'
export const useAvailableNodesMetaData = () => {
const { t } = useTranslation()
const isChatMode = useIsChatMode()
- const appType = useAppStore(s => s.appDetail?.type)
+ const appType = useAppStore(s => s.appDetail?.workflow_kind)
const docLink = useDocLink()
const isEvaluationWorkflowType = isEvaluationWorkflow(appType)
diff --git a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts
index 42946e14a8..0ac528c303 100644
--- a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts
+++ b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts
@@ -1,5 +1,6 @@
import type { SyncDraftCallback } from '@/app/components/workflow/hooks-store'
import type { WorkflowDraftFeaturesPayload } from '@/service/workflow'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { produce } from 'immer'
import { useCallback } from 'react'
import { useStoreApi } from 'reactflow'
@@ -9,8 +10,8 @@ import { useSerialAsyncCallback } from '@/app/components/workflow/hooks/use-seri
import { useNodesReadOnly } from '@/app/components/workflow/hooks/use-workflow'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { API_PREFIX } from '@/config'
-import { useGlobalPublicStore } from '@/context/global-public-context'
import { postWithKeepalive } from '@/service/fetch'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { syncWorkflowDraft } from '@/service/workflow'
import { useWorkflowRefreshDraft } from '.'
@@ -20,7 +21,10 @@ export const useNodesSyncDraft = () => {
const featuresStore = useFeaturesStore()
const { getNodesReadOnly } = useNodesReadOnly()
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
- const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
+ const { data: isCollaborationEnabled } = useSuspenseQuery({
+ ...systemFeaturesQueryOptions(),
+ select: s => s.enable_collaboration_mode,
+ })
const getPostParams = useCallback(() => {
const {
diff --git a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx
index 179e31e206..6ca94c2804 100644
--- a/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx
+++ b/web/app/components/workflow/__tests__/selection-contextmenu.spec.tsx
@@ -39,13 +39,19 @@ vi.mock('@/service/use-snippets', () => ({
}),
}))
-vi.mock('@/service/client', () => ({
- consoleClient: {
- snippets: {
- syncDraftWorkflow: (...args: unknown[]) => mockSyncDraftWorkflow(...args),
+vi.mock('@/service/client', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ consoleClient: {
+ ...actual.consoleClient,
+ snippets: {
+ ...actual.consoleClient.snippets,
+ syncDraftWorkflow: (...args: unknown[]) => mockSyncDraftWorkflow(...args),
+ },
},
- },
-}))
+ }
+})
vi.mock('../hooks', async () => {
const actual = await vi.importActual('../hooks')
@@ -294,13 +300,22 @@ describe('SelectionContextmenu', () => {
createEdge({ id: 'e2', source: 'n2', target: 'n3' }),
]
- const { store } = renderSelectionMenu({ nodes, edges })
+ const { store } = renderSelectionMenu({
+ nodes,
+ edges,
+ initialStoreState: {
+ workflowCanvasWidth: 800,
+ workflowCanvasHeight: 600,
+ },
+ })
act(() => {
store.setState({ selectionMenu: { clientX: 120, clientY: 120 } })
})
fireEvent.click(screen.getByTestId('selection-contextmenu-item-createSnippet'))
+ expect(store.getState().selectionMenu).toBeUndefined()
+ expect(screen.queryByTestId('selection-contextmenu-item-createSnippet')).not.toBeInTheDocument()
fireEvent.change(screen.getByPlaceholderText('workflow.snippet.namePlaceholder'), {
target: { value: 'My snippet' },
})
@@ -340,7 +355,7 @@ describe('SelectionContextmenu', () => {
selected: false,
}),
],
- viewport: { x: 0, y: 0, zoom: 1 },
+ viewport: { x: 300, y: 255, zoom: 1 },
},
},
})
diff --git a/web/app/components/workflow/__tests__/workflow-test-env.tsx b/web/app/components/workflow/__tests__/workflow-test-env.tsx
index bbc5fe98dd..549a055c1e 100644
--- a/web/app/components/workflow/__tests__/workflow-test-env.tsx
+++ b/web/app/components/workflow/__tests__/workflow-test-env.tsx
@@ -72,6 +72,7 @@ import * as React from 'react'
import ReactFlow, { ReactFlowProvider } from 'reactflow'
import { temporal } from 'zundo'
import { create } from 'zustand'
+import { seedSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { WorkflowContext } from '../context'
import { HooksStoreContext } from '../hooks-store/provider'
import { createHooksStore } from '../hooks-store/store'
@@ -134,6 +135,7 @@ type WorkflowProviderOptions = {
initialStoreState?: Partial
hooksStoreProps?: Partial
historyStore?: HistoryStoreConfig
+ queryClient?: QueryClient
}
type StoreInstances = {
@@ -152,17 +154,20 @@ function createStoresFromOptions(options: WorkflowProviderOptions): StoreInstanc
function createWorkflowWrapper(
stores: StoreInstances,
historyConfig?: HistoryStoreConfig,
+ externalQueryClient?: QueryClient,
) {
const historyCtxValue = historyConfig
? createTestHistoryStoreContext(historyConfig)
: undefined
- const queryClient = new QueryClient({
+ const queryClient = externalQueryClient ?? new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
+ if (!externalQueryClient)
+ seedSystemFeatures(queryClient)
return ({ children }: { children: React.ReactNode }) => {
let inner: React.ReactNode = children
@@ -215,10 +220,10 @@ export function renderWorkflowHook(
hook: (props: P) => R,
options?: WorkflowHookTestOptions,
): WorkflowHookTestResult {
- const { initialStoreState, hooksStoreProps, historyStore: historyConfig, ...rest } = options ?? {}
+ const { initialStoreState, hooksStoreProps, historyStore: historyConfig, queryClient, ...rest } = options ?? {}
const stores = createStoresFromOptions({ initialStoreState, hooksStoreProps })
- const wrapper = createWorkflowWrapper(stores, historyConfig)
+ const wrapper = createWorkflowWrapper(stores, historyConfig, queryClient)
const renderResult = renderHook(hook, { wrapper, ...rest })
return { ...renderResult, ...stores }
@@ -244,10 +249,10 @@ export function renderWorkflowComponent(
ui: React.ReactElement,
options?: WorkflowComponentTestOptions,
): WorkflowComponentTestResult {
- const { initialStoreState, hooksStoreProps, historyStore: historyConfig, ...renderOptions } = options ?? {}
+ const { initialStoreState, hooksStoreProps, historyStore: historyConfig, queryClient, ...renderOptions } = options ?? {}
const stores = createStoresFromOptions({ initialStoreState, hooksStoreProps })
- const wrapper = createWorkflowWrapper(stores, historyConfig)
+ const wrapper = createWorkflowWrapper(stores, historyConfig, queryClient)
const renderResult = render(ui, { wrapper, ...renderOptions })
return { ...renderResult, ...stores }
diff --git a/web/app/components/workflow/block-selector/__tests__/all-start-blocks.spec.tsx b/web/app/components/workflow/block-selector/__tests__/all-start-blocks.spec.tsx
index 0ffe583bb1..10b0b6b166 100644
--- a/web/app/components/workflow/block-selector/__tests__/all-start-blocks.spec.tsx
+++ b/web/app/components/workflow/block-selector/__tests__/all-start-blocks.spec.tsx
@@ -1,24 +1,20 @@
+import type { ReactElement } from 'react'
import type { TriggerWithProvider } from '../types'
-import { render, screen, waitFor } from '@testing-library/react'
+import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks'
import { CollectionType } from '@/app/components/tools/types'
-import { useGlobalPublicStore } from '@/context/global-public-context'
import { useGetLanguage, useLocale } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { useFeaturedTriggersRecommendations } from '@/service/use-plugins'
import { useAllTriggerPlugins, useInvalidateAllTriggerPlugins } from '@/service/use-triggers'
import { AppTypeEnum, Theme } from '@/types/app'
-import { defaultSystemFeatures } from '@/types/feature'
import { useAvailableNodesMetaData } from '../../../workflow-app/hooks'
import useNodes from '../../store/workflow/use-nodes'
import { BlockEnum } from '../../types'
import AllStartBlocks from '../all-start-blocks'
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: vi.fn(),
-}))
-
const mockAppType = vi.hoisted<{ current?: string }>(() => ({
current: 'workflow',
}))
@@ -69,7 +65,6 @@ vi.mock('@/utils/var', async (importOriginal) => {
}
})
-const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
const mockUseGetLanguage = vi.mocked(useGetLanguage)
const mockUseLocale = vi.mocked(useLocale)
const mockUseTheme = vi.mocked(useTheme)
@@ -118,15 +113,9 @@ const createTriggerProvider = (overrides: Partial = {}): Tr
...overrides,
})
-const createSystemFeatures = (enableMarketplace: boolean) => ({
- ...defaultSystemFeatures,
- enable_marketplace: enableMarketplace,
-})
-
-const createGlobalPublicStoreState = (enableMarketplace: boolean) => ({
- systemFeatures: createSystemFeatures(enableMarketplace),
- setSystemFeatures: vi.fn(),
-})
+let enableMarketplaceForRender = false
+const render = (ui: ReactElement) =>
+ renderWithSystemFeatures(ui, { systemFeatures: { enable_marketplace: enableMarketplaceForRender } })
const createMarketplacePluginsMock = (
overrides: Partial = {},
@@ -192,7 +181,7 @@ describe('AllStartBlocks', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAppType.current = AppTypeEnum.WORKFLOW
- mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(false)))
+ enableMarketplaceForRender = false
mockUseGetLanguage.mockReturnValue('en_US')
mockUseLocale.mockReturnValue('en_US')
mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType)
@@ -239,7 +228,7 @@ describe('AllStartBlocks', () => {
})
it('should show marketplace footer when marketplace is enabled without filters', async () => {
- mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(true)))
+ enableMarketplaceForRender = true
render(
{
describe('Filtered Empty State', () => {
it('should query marketplace and show the no-results state when filters have no matches', async () => {
const queryPluginsWithDebounced = vi.fn()
- mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(true)))
+ enableMarketplaceForRender = true
mockUseMarketplacePlugins.mockReturnValue(createMarketplacePluginsMock({
queryPluginsWithDebounced,
}))
diff --git a/web/app/components/workflow/block-selector/__tests__/all-tools.spec.tsx b/web/app/components/workflow/block-selector/__tests__/all-tools.spec.tsx
index 64f012fae3..9c7caeaa06 100644
--- a/web/app/components/workflow/block-selector/__tests__/all-tools.spec.tsx
+++ b/web/app/components/workflow/block-selector/__tests__/all-tools.spec.tsx
@@ -1,16 +1,13 @@
-import { render, screen, waitFor } from '@testing-library/react'
+import type { ReactElement } from 'react'
+import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks'
-import { useGlobalPublicStore } from '@/context/global-public-context'
import { useGetLanguage } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import AllTools from '../all-tools'
-import { createGlobalPublicStoreState, createToolProvider } from './factories'
-
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: vi.fn(),
-}))
+import { createToolProvider } from './factories'
vi.mock('@/context/i18n', () => ({
useGetLanguage: vi.fn(),
@@ -36,10 +33,12 @@ vi.mock('@/utils/var', async importOriginal => ({
}))
const mockUseMarketplacePlugins = vi.mocked(useMarketplacePlugins)
-const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
const mockUseGetLanguage = vi.mocked(useGetLanguage)
const mockUseTheme = vi.mocked(useTheme)
+const render = (ui: ReactElement, enableMarketplace = false) =>
+ renderWithSystemFeatures(ui, { systemFeatures: { enable_marketplace: enableMarketplace } })
+
const createMarketplacePluginsMock = () => ({
plugins: [],
total: 0,
@@ -57,7 +56,6 @@ const createMarketplacePluginsMock = () => ({
describe('AllTools', () => {
beforeEach(() => {
vi.clearAllMocks()
- mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(false)))
mockUseGetLanguage.mockReturnValue('en_US')
mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType)
mockUseMarketplacePlugins.mockReturnValue(createMarketplacePluginsMock())
diff --git a/web/app/components/workflow/block-selector/__tests__/data-sources.spec.tsx b/web/app/components/workflow/block-selector/__tests__/data-sources.spec.tsx
index 64bcd514c6..0d242dbf78 100644
--- a/web/app/components/workflow/block-selector/__tests__/data-sources.spec.tsx
+++ b/web/app/components/workflow/block-selector/__tests__/data-sources.spec.tsx
@@ -1,21 +1,17 @@
+import type { ReactElement } from 'react'
import type { ToolWithProvider } from '../../types'
-import { render, screen, waitFor } from '@testing-library/react'
+import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { CollectionType } from '@/app/components/tools/types'
-import { useGlobalPublicStore } from '@/context/global-public-context'
import { useGetLanguage } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
-import { defaultSystemFeatures } from '@/types/feature'
import { BlockEnum } from '../../types'
import DataSources from '../data-sources'
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: vi.fn(),
-}))
-
vi.mock('@/context/i18n', () => ({
useGetLanguage: vi.fn(),
}))
@@ -28,11 +24,14 @@ vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
useMarketplacePlugins: vi.fn(),
}))
-const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
const mockUseGetLanguage = vi.mocked(useGetLanguage)
const mockUseTheme = vi.mocked(useTheme)
const mockUseMarketplacePlugins = vi.mocked(useMarketplacePlugins)
+let enableMarketplaceForRender = false
+const render = (ui: ReactElement) =>
+ renderWithSystemFeatures(ui, { systemFeatures: { enable_marketplace: enableMarketplaceForRender } })
+
type UseMarketplacePluginsReturn = ReturnType
const createToolProvider = (overrides: Partial = {}): ToolWithProvider => ({
@@ -63,16 +62,6 @@ const createToolProvider = (overrides: Partial = {}): ToolWith
...overrides,
})
-const createSystemFeatures = (enableMarketplace: boolean) => ({
- ...defaultSystemFeatures,
- enable_marketplace: enableMarketplace,
-})
-
-const createGlobalPublicStoreState = (enableMarketplace: boolean) => ({
- systemFeatures: createSystemFeatures(enableMarketplace),
- setSystemFeatures: vi.fn(),
-})
-
const createMarketplacePluginsMock = (
overrides: Partial = {},
): UseMarketplacePluginsReturn => ({
@@ -93,7 +82,7 @@ const createMarketplacePluginsMock = (
describe('DataSources', () => {
beforeEach(() => {
vi.clearAllMocks()
- mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(false)))
+ enableMarketplaceForRender = false
mockUseGetLanguage.mockReturnValue('en_US')
mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType)
mockUseMarketplacePlugins.mockReturnValue(createMarketplacePluginsMock())
@@ -162,7 +151,7 @@ describe('DataSources', () => {
describe('Marketplace Search', () => {
it('should query marketplace plugins for datasource search results', async () => {
const queryPluginsWithDebounced = vi.fn()
- mockUseGlobalPublicStore.mockImplementation(selector => selector(createGlobalPublicStoreState(true)))
+ enableMarketplaceForRender = true
mockUseMarketplacePlugins.mockReturnValue(createMarketplacePluginsMock({
queryPluginsWithDebounced,
}))
diff --git a/web/app/components/workflow/block-selector/__tests__/factories.ts b/web/app/components/workflow/block-selector/__tests__/factories.ts
index b7d82f7cb3..dfb7c36aea 100644
--- a/web/app/components/workflow/block-selector/__tests__/factories.ts
+++ b/web/app/components/workflow/block-selector/__tests__/factories.ts
@@ -3,7 +3,6 @@ import type { Plugin } from '@/app/components/plugins/types'
import type { Tool } from '@/app/components/tools/types'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { CollectionType } from '@/app/components/tools/types'
-import { defaultSystemFeatures } from '@/types/feature'
export const createTool = (
name: string,
@@ -91,11 +90,3 @@ export const createPlugin = (overrides: Partial = {}): Plugin => ({
from: 'github',
...overrides,
})
-
-export const createGlobalPublicStoreState = (enableMarketplace: boolean) => ({
- systemFeatures: {
- ...defaultSystemFeatures,
- enable_marketplace: enableMarketplace,
- },
- setSystemFeatures: vi.fn(),
-})
diff --git a/web/app/components/workflow/block-selector/__tests__/index.spec.tsx b/web/app/components/workflow/block-selector/__tests__/index.spec.tsx
index 735a831c10..d426b43cfd 100644
--- a/web/app/components/workflow/block-selector/__tests__/index.spec.tsx
+++ b/web/app/components/workflow/block-selector/__tests__/index.spec.tsx
@@ -23,12 +23,6 @@ vi.mock('@/service/use-tools', () => ({
useInvalidateAllBuiltInTools: () => vi.fn(),
}))
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => unknown) => selector({
- systemFeatures: { enable_marketplace: false },
- }),
-}))
-
const createBlock = (type: BlockEnum, title: string): NodeDefault => ({
metaData: {
type,
diff --git a/web/app/components/workflow/block-selector/__tests__/main.spec.tsx b/web/app/components/workflow/block-selector/__tests__/main.spec.tsx
index 1deb6ce84c..2cb0d3e98f 100644
--- a/web/app/components/workflow/block-selector/__tests__/main.spec.tsx
+++ b/web/app/components/workflow/block-selector/__tests__/main.spec.tsx
@@ -14,12 +14,6 @@ vi.mock('reactflow', () => ({
}),
}))
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => unknown) => selector({
- systemFeatures: { enable_marketplace: false },
- }),
-}))
-
vi.mock('@/service/use-plugins', () => ({
useFeaturedToolsRecommendations: () => ({
plugins: [],
diff --git a/web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx b/web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx
index 3ee21f9999..3002cafa0a 100644
--- a/web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx
+++ b/web/app/components/workflow/block-selector/__tests__/tabs.spec.tsx
@@ -1,9 +1,13 @@
-import { fireEvent, render, screen } from '@testing-library/react'
+import { fireEvent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import Tabs from '../tabs'
import { TabsEnum } from '../types'
+const render = (ui: React.ReactElement) =>
+ renderWithSystemFeatures(ui, { systemFeatures: { enable_marketplace: true } })
+
const {
mockSetState,
mockInvalidateBuiltInTools,
@@ -34,12 +38,6 @@ vi.mock('@/app/components/base/tooltip', () => ({
),
}))
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => unknown) => selector({
- systemFeatures: { enable_marketplace: true },
- }),
-}))
-
vi.mock('@/service/use-plugins', () => ({
useFeaturedToolsRecommendations: () => ({
plugins: [],
diff --git a/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx b/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx
index 1a2f7a4c93..9c55d174fd 100644
--- a/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx
+++ b/web/app/components/workflow/block-selector/__tests__/tool-picker.spec.tsx
@@ -2,14 +2,13 @@ import type { ToolWithProvider } from '../../types'
import type { ToolValue } from '../types'
import type { Plugin } from '@/app/components/plugins/types'
import type { Tool } from '@/app/components/tools/types'
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { useTags } from '@/app/components/plugins/hooks'
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { CollectionType } from '@/app/components/tools/types'
-import { useGlobalPublicStore } from '@/context/global-public-context'
import { useGetLanguage } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { createCustomCollection } from '@/service/tools'
@@ -25,11 +24,9 @@ import {
useInvalidateAllWorkflowTools,
} from '@/service/use-tools'
import { Theme } from '@/types/app'
-import { defaultSystemFeatures } from '@/types/feature'
import ToolPicker from '../tool-picker'
const mockNotify = vi.fn()
-const mockSetSystemFeatures = vi.fn()
const mockInvalidateBuiltInTools = vi.fn()
const mockInvalidateCustomTools = vi.fn()
const mockInvalidateWorkflowTools = vi.fn()
@@ -39,7 +36,6 @@ const mockInstallPackageFromMarketPlace = vi.fn()
const mockCheckInstalled = vi.fn()
const mockRefreshPluginList = vi.fn()
-const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore)
const mockUseGetLanguage = vi.mocked(useGetLanguage)
const mockUseTheme = vi.mocked(useTheme)
const mockUseTags = vi.mocked(useTags)
@@ -54,10 +50,6 @@ const mockUseInvalidateAllWorkflowTools = vi.mocked(useInvalidateAllWorkflowTool
const mockUseInvalidateAllMCPTools = vi.mocked(useInvalidateAllMCPTools)
const mockUseFeaturedToolsRecommendations = vi.mocked(useFeaturedToolsRecommendations)
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: vi.fn(),
-}))
-
vi.mock('@/context/i18n', () => ({
useGetLanguage: vi.fn(),
}))
@@ -313,27 +305,18 @@ const mcpTools = [
]
const renderToolPicker = (props: Partial> = {}) => {
- const queryClient = new QueryClient({
- defaultOptions: {
- queries: {
- retry: false,
- },
- },
- })
-
- return render(
-
- open-picker}
- isShow={false}
- onShowChange={vi.fn()}
- onSelect={vi.fn()}
- onSelectMultiple={vi.fn()}
- selectedTools={[createToolValue()]}
- {...props}
- />
- ,
+ return renderWithSystemFeatures(
+ open-picker}
+ isShow={false}
+ onShowChange={vi.fn()}
+ onSelect={vi.fn()}
+ onSelectMultiple={vi.fn()}
+ selectedTools={[createToolValue()]}
+ {...props}
+ />,
+ { systemFeatures: { enable_marketplace: true } },
)
}
@@ -341,13 +324,6 @@ describe('ToolPicker', () => {
beforeEach(() => {
vi.clearAllMocks()
- mockUseGlobalPublicStore.mockImplementation(selector => selector({
- systemFeatures: {
- ...defaultSystemFeatures,
- enable_marketplace: true,
- },
- setSystemFeatures: mockSetSystemFeatures,
- }))
mockUseGetLanguage.mockReturnValue('en_US')
mockUseTheme.mockReturnValue({ theme: Theme.light } as ReturnType)
mockUseTags.mockReturnValue({
diff --git a/web/app/components/workflow/block-selector/all-start-blocks.tsx b/web/app/components/workflow/block-selector/all-start-blocks.tsx
index 5c6bff935e..605a3eb5b7 100644
--- a/web/app/components/workflow/block-selector/all-start-blocks.tsx
+++ b/web/app/components/workflow/block-selector/all-start-blocks.tsx
@@ -8,6 +8,7 @@ import type { TriggerDefaultValue, TriggerWithProvider } from './types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowRightUpLine } from '@remixicon/react'
+import { useSuspenseQuery } from '@tanstack/react-query'
import {
useCallback,
useEffect,
@@ -20,8 +21,8 @@ import { useStore as useAppStore } from '@/app/components/app/store'
import Divider from '@/app/components/base/divider'
import { SearchMenu } from '@/app/components/base/icons/src/vender/line/general'
import { filterEvaluationWorkflowRestrictedBlockTypes, isEvaluationWorkflow } from '@/app/components/workflow/utils/evaluation-workflow'
-import { useGlobalPublicStore } from '@/context/global-public-context'
import Link from '@/next/link'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useFeaturedTriggersRecommendations } from '@/service/use-plugins'
import { useAllTriggerPlugins, useInvalidateAllTriggerPlugins } from '@/service/use-triggers'
import { getMarketplaceUrl } from '@/utils/var'
@@ -56,8 +57,11 @@ const AllStartBlocks = ({
const { t } = useTranslation()
const [hasStartBlocksContent, setHasStartBlocksContent] = useState(false)
const [hasPluginContent, setHasPluginContent] = useState(false)
- const appType = useAppStore(s => s.appDetail?.type)
- const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
+ const appType = useAppStore(s => s.appDetail?.workflow_kind)
+ const { data: enable_marketplace } = useSuspenseQuery({
+ ...systemFeaturesQueryOptions(),
+ select: s => s.enable_marketplace,
+ })
const pluginRef = useRef(null)
const wrapElemRef = useRef(null)
diff --git a/web/app/components/workflow/block-selector/all-tools.tsx b/web/app/components/workflow/block-selector/all-tools.tsx
index 2483b92f90..72389ec376 100644
--- a/web/app/components/workflow/block-selector/all-tools.tsx
+++ b/web/app/components/workflow/block-selector/all-tools.tsx
@@ -14,14 +14,15 @@ import type { OnSelectBlock } from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowRightUpLine } from '@remixicon/react'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { SearchMenu } from '@/app/components/base/icons/src/vender/line/general'
import PluginList from '@/app/components/workflow/block-selector/market-place-plugin/list'
-import { useGlobalPublicStore } from '@/context/global-public-context'
import { useGetLanguage } from '@/context/i18n'
import Link from '@/next/link'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { getMarketplaceUrl } from '@/utils/var'
import { useMarketplacePlugins } from '../../plugins/marketplace/hooks'
import { PluginCategoryEnum } from '../../plugins/types'
@@ -167,7 +168,10 @@ const AllTools = ({
plugins: notInstalledPlugins = [],
} = useMarketplacePlugins()
- const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
+ const { data: enable_marketplace } = useSuspenseQuery({
+ ...systemFeaturesQueryOptions(),
+ select: s => s.enable_marketplace,
+ })
useEffect(() => {
if (!enable_marketplace)
diff --git a/web/app/components/workflow/block-selector/blocks.tsx b/web/app/components/workflow/block-selector/blocks.tsx
index b152fd1afa..70497ec69d 100644
--- a/web/app/components/workflow/block-selector/blocks.tsx
+++ b/web/app/components/workflow/block-selector/blocks.tsx
@@ -31,7 +31,7 @@ const Blocks = ({
}: BlocksProps) => {
const { t } = useTranslation()
const store = useStoreApi()
- const appType = useAppStore(s => s.appDetail?.type)
+ const appType = useAppStore(s => s.appDetail?.workflow_kind)
const blocksFromHooks = useBlocks()
const filteredAvailableBlocksTypes = useMemo(() => {
if (!isEvaluationWorkflow(appType))
diff --git a/web/app/components/workflow/block-selector/data-sources.tsx b/web/app/components/workflow/block-selector/data-sources.tsx
index 447442818a..1ee2bf9b9b 100644
--- a/web/app/components/workflow/block-selector/data-sources.tsx
+++ b/web/app/components/workflow/block-selector/data-sources.tsx
@@ -5,6 +5,7 @@ import type {
import type { DataSourceDefaultValue, ToolDefaultValue } from './types'
import type { ListRef } from '@/app/components/workflow/block-selector/market-place-plugin/list'
import { cn } from '@langgenius/dify-ui/cn'
+import { useSuspenseQuery } from '@tanstack/react-query'
import {
useCallback,
useEffect,
@@ -12,8 +13,8 @@ import {
useRef,
} from 'react'
import PluginList from '@/app/components/workflow/block-selector/market-place-plugin/list'
-import { useGlobalPublicStore } from '@/context/global-public-context'
import { useGetLanguage } from '@/context/i18n'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useMarketplacePlugins } from '../../plugins/marketplace/hooks'
import { PluginCategoryEnum } from '../../plugins/types'
import { BlockEnum } from '../types'
@@ -76,7 +77,10 @@ const DataSources = ({
onSelect(BlockEnum.DataSource, toolDefaultValue && defaultValue)
}, [onSelect])
- const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
+ const { data: enable_marketplace } = useSuspenseQuery({
+ ...systemFeaturesQueryOptions(),
+ select: s => s.enable_marketplace,
+ })
const {
queryPluginsWithDebounced: fetchPlugins,
diff --git a/web/app/components/workflow/block-selector/snippets/index.tsx b/web/app/components/workflow/block-selector/snippets/index.tsx
index 56100bd8ff..75854b26e9 100644
--- a/web/app/components/workflow/block-selector/snippets/index.tsx
+++ b/web/app/components/workflow/block-selector/snippets/index.tsx
@@ -1,13 +1,4 @@
import { cn } from '@langgenius/dify-ui/cn'
-import { useInfiniteScroll } from 'ahooks'
-import {
- memo,
- useDeferredValue,
- useMemo,
- useRef,
- useState,
-} from 'react'
-import Loading from '@/app/components/base/loading'
import {
ScrollAreaContent,
ScrollAreaRoot,
@@ -20,6 +11,15 @@ import {
TooltipContent,
TooltipTrigger,
} from '@langgenius/dify-ui/tooltip'
+import { useInfiniteScroll } from 'ahooks'
+import {
+ memo,
+ useDeferredValue,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
+import Loading from '@/app/components/base/loading'
import { useInfiniteSnippetList } from '@/service/use-snippets'
import CreateSnippetDialog from '../../create-snippet-dialog'
import SnippetDetailCard from './snippet-detail-card'
@@ -118,54 +118,54 @@ const Snippets = ({
<>
{!snippets.length
? (
-
- )
+
+ )
: (
-
-
-
- {snippets.map((item) => {
- const row = (
- handleInsertSnippet(item.id)}
- onMouseEnter={() => setHoveredSnippetId(item.id)}
- onMouseLeave={() => setHoveredSnippetId(current => current === item.id ? null : current)}
- />
- )
-
- if (!item.description)
- return {row}
-
- return (
-
-
+
+
+ {snippets.map((item) => {
+ const row = (
+ handleInsertSnippet(item.id)}
+ onMouseEnter={() => setHoveredSnippetId(item.id)}
+ onMouseLeave={() => setHoveredSnippetId(current => current === item.id ? null : current)}
/>
-
-
-
-
- )
- })}
- {isFetchingNextPage && (
-
-
-
- )}
-
-
-
-
-
-
- )}
+ )
+
+ if (!item.description)
+ return {row}
+
+ return (
+
+
+
+
+
+
+ )
+ })}
+ {isFetchingNextPage && (
+
+
+
+ )}
+
+
+
+
+
+
+ )}
void
@@ -14,7 +14,7 @@ const SnippetEmptyState: FC = ({
return (
-
+
{t('tabs.noSnippetsFound', { ns: 'workflow' })}
node.position.y)) : 0
const currentMaxX = currentNodes.length
? Math.max(...currentNodes.map((node) => {
- const nodeX = node.positionAbsolute?.x ?? node.position.x
- return nodeX + (node.width ?? 0)
- }))
+ const nodeX = node.positionAbsolute?.x ?? node.position.x
+ return nodeX + (node.width ?? 0)
+ }))
: 0
const currentMinY = currentNodes.length
? Math.min(...currentNodes.map(node => node.positionAbsolute?.y ?? node.position.y))
@@ -53,17 +53,17 @@ const remapSnippetGraph = (currentNodes: Node[], snippetNodes: Node[], snippetEd
parentId: nextParentId,
position: isRootNode
? {
- x: node.position.x + offsetX,
- y: node.position.y + offsetY,
- }
+ x: node.position.x + offsetX,
+ y: node.position.y + offsetY,
+ }
: node.position,
positionAbsolute: node.positionAbsolute
? (isRootNode
- ? {
- x: node.positionAbsolute.x + offsetX,
- y: node.positionAbsolute.y + offsetY,
- }
- : node.positionAbsolute)
+ ? {
+ x: node.positionAbsolute.x + offsetX,
+ y: node.positionAbsolute.y + offsetY,
+ }
+ : node.positionAbsolute)
: undefined,
selected: true,
data: {
@@ -85,9 +85,9 @@ const remapSnippetGraph = (currentNodes: Node[], snippetNodes: Node[], snippetEd
selected: false,
data: edge.data
? {
- ...edge.data,
- _connectedNodeIsSelected: true,
- }
+ ...edge.data,
+ _connectedNodeIsSelected: true,
+ }
: edge.data,
}))
diff --git a/web/app/components/workflow/block-selector/tabs.tsx b/web/app/components/workflow/block-selector/tabs.tsx
index 834c8d5957..8262146c26 100644
--- a/web/app/components/workflow/block-selector/tabs.tsx
+++ b/web/app/components/workflow/block-selector/tabs.tsx
@@ -6,10 +6,11 @@ import type {
ToolWithProvider,
} from '../types'
import { cn } from '@langgenius/dify-ui/cn'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { memo, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useFeaturedToolsRecommendations } from '@/service/use-plugins'
import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools, useInvalidateAllBuiltInTools } from '@/service/use-tools'
import { basePath } from '@/utils/var'
@@ -183,7 +184,10 @@ const Tabs: FC = ({
const { data: workflowTools } = useAllWorkflowTools()
const { data: mcpTools } = useAllMCPTools()
const invalidateBuiltInTools = useInvalidateAllBuiltInTools()
- const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
+ const { data: enable_marketplace } = useSuspenseQuery({
+ ...systemFeaturesQueryOptions(),
+ select: s => s.enable_marketplace,
+ })
const workflowStore = useWorkflowStore()
const inRAGPipeline = dataSources.length > 0
const {
diff --git a/web/app/components/workflow/block-selector/tool-picker.tsx b/web/app/components/workflow/block-selector/tool-picker.tsx
index 7ba93798e2..e7e948a54d 100644
--- a/web/app/components/workflow/block-selector/tool-picker.tsx
+++ b/web/app/components/workflow/block-selector/tool-picker.tsx
@@ -9,6 +9,7 @@ import type { CustomCollectionBackend } from '@/app/components/tools/types'
import type { BlockEnum, OnSelectBlock } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useMemo, useState } from 'react'
@@ -21,7 +22,7 @@ import {
import SearchBox from '@/app/components/plugins/marketplace/search-box'
import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal'
import AllTools from '@/app/components/workflow/block-selector/all-tools'
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import {
createCustomCollection,
} from '@/service/tools'
@@ -70,7 +71,10 @@ const ToolPicker: FC = ({
const [searchText, setSearchText] = useState('')
const [tags, setTags] = useState([])
- const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
+ const { data: enable_marketplace } = useSuspenseQuery({
+ ...systemFeaturesQueryOptions(),
+ select: s => s.enable_marketplace,
+ })
const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools()
const invalidateCustomTools = useInvalidateAllCustomTools()
diff --git a/web/app/components/workflow/collaboration/hooks/__tests__/use-collaboration.spec.ts b/web/app/components/workflow/collaboration/hooks/__tests__/use-collaboration.spec.ts
index 0f8a9e2c9a..7c063ab2cf 100644
--- a/web/app/components/workflow/collaboration/hooks/__tests__/use-collaboration.spec.ts
+++ b/web/app/components/workflow/collaboration/hooks/__tests__/use-collaboration.spec.ts
@@ -1,5 +1,6 @@
import type { CursorPosition, NodePanelPresenceMap, OnlineUser } from '../../types/collaboration'
-import { renderHook, waitFor } from '@testing-library/react'
+import { waitFor } from '@testing-library/react'
+import { renderHookWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { useCollaboration } from '../use-collaboration'
type HookReactFlowStore = NonNullable[1]>
@@ -29,11 +30,6 @@ const mockStartTracking = vi.hoisted(() => vi.fn())
const mockStopTracking = vi.hoisted(() => vi.fn())
const cursorServiceInstances: Array<{ startTracking: typeof mockStartTracking, stopTracking: typeof mockStopTracking }> = []
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_collaboration_mode: boolean } }) => boolean) =>
- selector({ systemFeatures: { enable_collaboration_mode: isCollaborationEnabled } }),
-}))
-
vi.mock('../../core/collaboration-manager', () => ({
collaborationManager: {
connect: (...args: unknown[]) => mockConnect(...args),
@@ -92,7 +88,9 @@ describe('useCollaboration', () => {
const reactFlowStore: HookReactFlowStore = {
getState: vi.fn(),
}
- const { result, unmount } = renderHook(() => useCollaboration('app-1', reactFlowStore))
+ const { result, unmount } = renderHookWithSystemFeatures(() => useCollaboration('app-1', reactFlowStore), {
+ systemFeatures: { enable_collaboration_mode: isCollaborationEnabled },
+ })
await waitFor(() => {
expect(mockConnect).toHaveBeenCalledWith('app-1', reactFlowStore)
@@ -138,7 +136,9 @@ describe('useCollaboration', () => {
it('does not connect or start cursor tracking when collaboration is disabled', async () => {
isCollaborationEnabled = false
- const { result } = renderHook(() => useCollaboration('app-1'))
+ const { result } = renderHookWithSystemFeatures(() => useCollaboration('app-1'), {
+ systemFeatures: { enable_collaboration_mode: isCollaborationEnabled },
+ })
await waitFor(() => {
expect(mockConnect).not.toHaveBeenCalled()
diff --git a/web/app/components/workflow/collaboration/hooks/use-collaboration.ts b/web/app/components/workflow/collaboration/hooks/use-collaboration.ts
index b24d9faea8..0030ffb77c 100644
--- a/web/app/components/workflow/collaboration/hooks/use-collaboration.ts
+++ b/web/app/components/workflow/collaboration/hooks/use-collaboration.ts
@@ -5,8 +5,9 @@ import type {
NodePanelPresenceMap,
OnlineUser,
} from '../types/collaboration'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { collaborationManager } from '../core/collaboration-manager'
import { CursorService } from '../services/cursor-service'
@@ -33,7 +34,10 @@ export function useCollaboration(appId: string, reactFlowStore?: ReactFlowStore)
const cursorServiceRef = useRef(null)
const lastDisconnectReasonRef = useRef(null)
- const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
+ const { data: isCollaborationEnabled } = useSuspenseQuery({
+ ...systemFeaturesQueryOptions(),
+ select: s => s.enable_collaboration_mode,
+ })
useEffect(() => {
if (!appId || !isCollaborationEnabled) {
diff --git a/web/app/components/workflow/create-snippet-dialog.tsx b/web/app/components/workflow/create-snippet-dialog.tsx
index 7271ad7ded..95f602f601 100644
--- a/web/app/components/workflow/create-snippet-dialog.tsx
+++ b/web/app/components/workflow/create-snippet-dialog.tsx
@@ -3,15 +3,15 @@
import type { FC } from 'react'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { SnippetCanvasData } from '@/models/snippet'
+import { Button } from '@langgenius/dify-ui/button'
+import { Dialog, DialogCloseButton, DialogContent, DialogPortal, DialogTitle } from '@langgenius/dify-ui/dialog'
import { useKeyPress } from 'ahooks'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import AppIconPicker from '@/app/components/base/app-icon-picker'
-import { Button } from '@langgenius/dify-ui/button'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
-import { Dialog, DialogCloseButton, DialogContent, DialogPortal, DialogTitle } from '@langgenius/dify-ui/dialog'
import ShortcutsName from './shortcuts-name'
export type CreateSnippetDialogPayload = {
diff --git a/web/app/components/workflow/hooks/__tests__/use-leader-restore.spec.ts b/web/app/components/workflow/hooks/__tests__/use-leader-restore.spec.ts
index a050994f4c..b0fb43f768 100644
--- a/web/app/components/workflow/hooks/__tests__/use-leader-restore.spec.ts
+++ b/web/app/components/workflow/hooks/__tests__/use-leader-restore.spec.ts
@@ -2,6 +2,7 @@ import type { RestoreIntentData, RestoreRequestData } from '../../collaboration/
import type { SyncDraftCallback } from '../../hooks-store/store'
import type { Edge, Node } from '../../types'
import { act, renderHook } from '@testing-library/react'
+import { renderHookWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { ChatVarType } from '../../panel/chat-variable-panel/type'
import { useLeaderRestore, useLeaderRestoreListener } from '../use-leader-restore'
@@ -72,11 +73,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
},
}))
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_collaboration_mode: boolean } }) => boolean) =>
- selector({ systemFeatures: { enable_collaboration_mode: isCollaborationEnabled } }),
-}))
-
vi.mock('../../store', () => ({
useWorkflowStore: () => ({
getState: () => ({
@@ -155,7 +151,9 @@ describe('useLeaderRestore', () => {
const onSuccess = vi.fn()
const onSettled = vi.fn()
- const { result } = renderHook(() => useLeaderRestore())
+ const { result } = renderHookWithSystemFeatures(() => useLeaderRestore(), {
+ systemFeatures: { enable_collaboration_mode: isCollaborationEnabled },
+ })
await act(async () => {
result.current.requestRestore(restoreData, { onSuccess, onSettled })
@@ -186,7 +184,9 @@ describe('useLeaderRestore', () => {
const onError = vi.fn()
const onSettled = vi.fn()
- const { result } = renderHook(() => useLeaderRestore())
+ const { result } = renderHookWithSystemFeatures(() => useLeaderRestore(), {
+ systemFeatures: { enable_collaboration_mode: isCollaborationEnabled },
+ })
act(() => {
result.current.requestRestore(restoreData, { onSuccess, onError, onSettled })
diff --git a/web/app/components/workflow/hooks/__tests__/use-workflow-comment.spec.ts b/web/app/components/workflow/hooks/__tests__/use-workflow-comment.spec.ts
index 5745603a5e..b2edfa5234 100644
--- a/web/app/components/workflow/hooks/__tests__/use-workflow-comment.spec.ts
+++ b/web/app/components/workflow/hooks/__tests__/use-workflow-comment.spec.ts
@@ -1,5 +1,6 @@
import type { WorkflowCommentDetail, WorkflowCommentList } from '@/service/workflow-comment'
import { act, waitFor } from '@testing-library/react'
+import { createTestQueryClient, seedSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { ControlMode } from '../../types'
import { useWorkflowComment } from '../use-workflow-comment'
@@ -51,14 +52,6 @@ vi.mock('@/context/app-context', () => ({
}),
}))
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_collaboration_mode: boolean } }) => unknown) => selector({
- systemFeatures: {
- enable_collaboration_mode: globalFeatureState.enableCollaboration,
- },
- }),
-}))
-
vi.mock('@/service/workflow-comment', () => ({
createWorkflowComment: (...args: unknown[]) => mockCreateWorkflowComment(...args),
createWorkflowCommentReply: (...args: unknown[]) => mockCreateWorkflowCommentReply(...args),
@@ -120,6 +113,14 @@ const baseCommentDetail = (): WorkflowCommentDetail => ({
replies: [],
})
+const createSeededQueryClient = () => {
+ const queryClient = createTestQueryClient()
+ seedSystemFeatures(queryClient, {
+ enable_collaboration_mode: globalFeatureState.enableCollaboration,
+ })
+ return queryClient
+}
+
describe('useWorkflowComment', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -139,7 +140,9 @@ describe('useWorkflowComment', () => {
const comment = baseComment()
mockFetchWorkflowComments.mockResolvedValue([comment])
- const { store } = renderWorkflowHook(() => useWorkflowComment())
+ const { store } = renderWorkflowHook(() => useWorkflowComment(), {
+ queryClient: createSeededQueryClient(),
+ })
await waitFor(() => {
expect(mockFetchWorkflowComments).toHaveBeenCalledWith('app-1')
@@ -152,7 +155,9 @@ describe('useWorkflowComment', () => {
it('does not load comment list when collaboration is disabled', async () => {
globalFeatureState.enableCollaboration = false
- renderWorkflowHook(() => useWorkflowComment())
+ renderWorkflowHook(() => useWorkflowComment(), {
+ queryClient: createSeededQueryClient(),
+ })
await Promise.resolve()
@@ -161,6 +166,7 @@ describe('useWorkflowComment', () => {
it('creates a comment, updates local cache, and emits collaboration sync', async () => {
const { result, store } = renderWorkflowHook(() => useWorkflowComment(), {
+ queryClient: createSeededQueryClient(),
initialStoreState: {
comments: [],
pendingComment: { pageX: 100, pageY: 200, elementX: 10, elementY: 20 },
@@ -214,6 +220,7 @@ describe('useWorkflowComment', () => {
mockUpdateWorkflowComment.mockRejectedValue(new Error('update failed'))
const { result, store } = renderWorkflowHook(() => useWorkflowComment(), {
+ queryClient: createSeededQueryClient(),
initialStoreState: {
comments: [comment],
activeCommentId: comment.id,
@@ -254,6 +261,7 @@ describe('useWorkflowComment', () => {
mockFetchWorkflowComment.mockResolvedValue({ data: detail })
const { unmount } = renderWorkflowHook(() => useWorkflowComment(), {
+ queryClient: createSeededQueryClient(),
initialStoreState: {
activeCommentId: comment.id,
},
@@ -295,6 +303,7 @@ describe('useWorkflowComment', () => {
})
const { result, store } = renderWorkflowHook(() => useWorkflowComment(), {
+ queryClient: createSeededQueryClient(),
initialStoreState: {
comments: [commentA, commentB],
commentDetailCache: {
@@ -363,6 +372,7 @@ describe('useWorkflowComment', () => {
})
const { result, store } = renderWorkflowHook(() => useWorkflowComment(), {
+ queryClient: createSeededQueryClient(),
initialStoreState: {
comments: [commentA, commentB],
activeCommentId: commentA.id,
diff --git a/web/app/components/workflow/hooks/use-leader-restore.ts b/web/app/components/workflow/hooks/use-leader-restore.ts
index da0036a4ea..9083767f40 100644
--- a/web/app/components/workflow/hooks/use-leader-restore.ts
+++ b/web/app/components/workflow/hooks/use-leader-restore.ts
@@ -1,12 +1,13 @@
import type { RestoreCompleteData, RestoreIntentData, RestoreRequestData } from '../collaboration/types/collaboration'
import type { SyncCallback } from './use-nodes-sync-draft'
import { toast } from '@langgenius/dify-ui/toast'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useReactFlow } from 'reactflow'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useFeaturesStore } from '@/app/components/base/features/hooks'
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { collaborationManager } from '../collaboration/core/collaboration-manager'
import { useWorkflowStore } from '../store'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
@@ -115,7 +116,10 @@ export const useLeaderRestore = () => {
versionId: string
callbacks: RestoreCallbacks | null
} | null>(null)
- const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
+ const { data: isCollaborationEnabled } = useSuspenseQuery({
+ ...systemFeaturesQueryOptions(),
+ select: s => s.enable_collaboration_mode,
+ })
const requestRestore = useCallback((data: RestoreRequestData, callbacks?: RestoreCallbacks) => {
if (!isCollaborationEnabled || !collaborationManager.isConnected() || collaborationManager.getIsLeader()) {
diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts
index a29c88e9cb..f0e23586eb 100644
--- a/web/app/components/workflow/hooks/use-nodes-interactions.ts
+++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts
@@ -14,6 +14,7 @@ import type { VariableAssignerNodeType } from '../nodes/variable-assigner/types'
import type { Edge, Node, OnNodeAdd } from '../types'
import type { RAGPipelineVariables } from '@/models/pipeline'
import { toast } from '@langgenius/dify-ui/toast'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { produce } from 'immer'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -22,7 +23,7 @@ import {
getOutgoers,
useReactFlow,
} from 'reactflow'
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { collaborationManager } from '../collaboration/core/collaboration-manager'
import {
CUSTOM_EDGE,
@@ -138,7 +139,10 @@ const getUniquePastedNodeTitle = (
export const useNodesInteractions = () => {
const { t } = useTranslation()
- const appDslVersion = useGlobalPublicStore(s => s.systemFeatures.app_dsl_version)
+ const { data: appDslVersion } = useSuspenseQuery({
+ ...systemFeaturesQueryOptions(),
+ select: s => s.app_dsl_version,
+ })
const collaborativeWorkflow = useCollaborativeWorkflow()
const workflowStore = useWorkflowStore()
const reactflow = useReactFlow()
diff --git a/web/app/components/workflow/hooks/use-panel-interactions.ts b/web/app/components/workflow/hooks/use-panel-interactions.ts
index 37120068d4..3b7a2158a1 100644
--- a/web/app/components/workflow/hooks/use-panel-interactions.ts
+++ b/web/app/components/workflow/hooks/use-panel-interactions.ts
@@ -1,12 +1,16 @@
import type { MouseEvent } from 'react'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback } from 'react'
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useWorkflowStore } from '../store'
import { readWorkflowClipboard } from '../utils'
export const usePanelInteractions = () => {
const workflowStore = useWorkflowStore()
- const appDslVersion = useGlobalPublicStore(s => s.systemFeatures.app_dsl_version)
+ const { data: appDslVersion } = useSuspenseQuery({
+ ...systemFeaturesQueryOptions(),
+ select: s => s.app_dsl_version,
+ })
const handlePaneContextMenu = useCallback((e: MouseEvent) => {
e.preventDefault()
diff --git a/web/app/components/workflow/hooks/use-workflow-comment.ts b/web/app/components/workflow/hooks/use-workflow-comment.ts
index 5b3f2e17b0..cdd14ceef1 100644
--- a/web/app/components/workflow/hooks/use-workflow-comment.ts
+++ b/web/app/components/workflow/hooks/use-workflow-comment.ts
@@ -1,10 +1,11 @@
import type { UserProfile, WorkflowCommentDetail, WorkflowCommentList } from '@/service/workflow-comment'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useRef } from 'react'
import { useReactFlow } from 'reactflow'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { useAppContext } from '@/context/app-context'
-import { useGlobalPublicStore } from '@/context/global-public-context'
import { useParams } from '@/next/navigation'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { createWorkflowComment, createWorkflowCommentReply, deleteWorkflowComment, deleteWorkflowCommentReply, fetchWorkflowComment, fetchWorkflowComments, resolveWorkflowComment, updateWorkflowComment, updateWorkflowCommentReply } from '@/service/workflow-comment'
import { useStore } from '../store'
import { ControlMode } from '../types'
@@ -50,7 +51,10 @@ export const useWorkflowComment = () => {
appId ? state.mentionableUsersCache[appId] ?? EMPTY_USERS : EMPTY_USERS
))
const { userProfile } = useAppContext()
- const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
+ const { data: isCollaborationEnabled } = useSuspenseQuery({
+ ...systemFeaturesQueryOptions(),
+ select: s => s.enable_collaboration_mode,
+ })
const commentDetailCacheRef = useRef>(commentDetailCache)
const activeCommentIdRef = useRef(null)
diff --git a/web/app/components/workflow/hooks/use-workflow-panel-interactions.ts b/web/app/components/workflow/hooks/use-workflow-panel-interactions.ts
index 62628c533e..e7ca512d34 100644
--- a/web/app/components/workflow/hooks/use-workflow-panel-interactions.ts
+++ b/web/app/components/workflow/hooks/use-workflow-panel-interactions.ts
@@ -1,5 +1,6 @@
+import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback } from 'react'
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useStore, useWorkflowStore } from '../store'
import { ControlMode } from '../types'
import { useEdgesInteractionsWithoutSync } from './use-edges-interactions-without-sync'
@@ -30,7 +31,10 @@ export const useWorkflowMoveMode = () => {
const setControlMode = useStore(s => s.setControlMode)
const { getNodesReadOnly } = useNodesReadOnly()
const { handleSelectionCancel } = useSelectionInteractions()
- const isCommentModeAvailable = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
+ const { data: isCommentModeAvailable } = useSuspenseQuery({
+ ...systemFeaturesQueryOptions(),
+ select: s => s.enable_collaboration_mode,
+ })
const handleModePointer = useCallback(() => {
if (getNodesReadOnly())
diff --git a/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx b/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx
index 489c69412a..8e7c1609d1 100644
--- a/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx
+++ b/web/app/components/workflow/nodes/_base/components/agent-strategy-selector.tsx
@@ -5,6 +5,7 @@ import type { StrategyPluginDetail } from '@/app/components/plugins/types'
import type { ListProps, ListRef } from '@/app/components/workflow/block-selector/market-place-plugin/list'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowDownSLine, RiErrorWarningFill } from '@remixicon/react'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
@@ -16,8 +17,8 @@ import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hook
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { CollectionType } from '@/app/components/tools/types'
import PluginList from '@/app/components/workflow/block-selector/market-place-plugin/list'
-import { useGlobalPublicStore } from '@/context/global-public-context'
import Link from '@/next/link'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useStrategyProviders } from '@/service/use-strategy'
import Tools from '../../../block-selector/tools'
import ViewTypeSelect, { ViewType } from '../../../block-selector/view-type-select'
@@ -95,7 +96,10 @@ type AgentStrategySelectorProps = {
}
export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) => {
- const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
+ const { data: enable_marketplace } = useSuspenseQuery({
+ ...systemFeaturesQueryOptions(),
+ select: s => s.enable_marketplace,
+ })
const { value, onChange } = props
const [open, setOpen] = useState(false)
diff --git a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/import-from-tool.spec.tsx b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/import-from-tool.spec.tsx
index f1da87bed2..7e13b8ef34 100644
--- a/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/import-from-tool.spec.tsx
+++ b/web/app/components/workflow/nodes/parameter-extractor/components/extract-parameter/__tests__/import-from-tool.spec.tsx
@@ -2,6 +2,7 @@ import type { ToolParameter } from '@/app/components/tools/types'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
+import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features'
import { CollectionType } from '@/app/components/tools/types'
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { createTool, createToolProvider } from '@/app/components/workflow/block-selector/__tests__/factories'
@@ -15,12 +16,6 @@ vi.mock('reactflow', () => ({
}),
}))
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => unknown) => selector({
- systemFeatures: { enable_marketplace: false },
- }),
-}))
-
vi.mock('@/service/use-plugins', () => ({
useFeaturedToolsRecommendations: () => ({
plugins: [],
@@ -65,6 +60,20 @@ const createToolParameter = (overrides: Partial = {}): ToolParame
...overrides,
})
+const renderImportFromTool = (ui: React.ReactElement) => {
+ const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({
+ systemFeatures: { enable_marketplace: false },
+ })
+ return renderWorkflowComponent(
+ {ui} ,
+ {
+ hooksStoreProps: {
+ availableNodesMetaData: { nodes: [] },
+ },
+ },
+ )
+}
+
describe('parameter-extractor/extract-parameter/import-from-tool', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -97,14 +106,7 @@ describe('parameter-extractor/extract-parameter/import-from-tool', () => {
provider.tools[0]!.parameters = builtInParameters
mockToolCollections.builtIn = [provider]
- renderWorkflowComponent(
- ,
- {
- hooksStoreProps: {
- availableNodesMetaData: { nodes: [] },
- },
- },
- )
+ renderImportFromTool( )
await user.click(screen.getByText('workflow.nodes.parameterExtractor.importFromTool'))
await user.click(await screen.findByText('Provider One'))
@@ -136,14 +138,7 @@ describe('parameter-extractor/extract-parameter/import-from-tool', () => {
provider.tools[0]!.parameters = workflowParameters
mockToolCollections.workflow = [provider]
- renderWorkflowComponent(
- ,
- {
- hooksStoreProps: {
- availableNodesMetaData: { nodes: [] },
- },
- },
- )
+ renderImportFromTool( )
await user.click(screen.getByText('workflow.nodes.parameterExtractor.importFromTool'))
await user.click(await screen.findByText('Workflow Tool'))
diff --git a/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx b/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx
index 9d38d112e9..990153d308 100644
--- a/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx
+++ b/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx
@@ -1,10 +1,11 @@
import type { ToolVarInputs } from '../../types'
import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { App } from '@/types/app'
-import { render, screen } from '@testing-library/react'
+import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import {
ConfigurationMethodEnum,
FormTypeEnum,
@@ -211,14 +212,6 @@ vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference
),
}))
-vi.mock('@/context/global-public-context', () => ({
- useSystemFeaturesQuery: () => ({
- data: {
- trial_models: [],
- },
- }),
-}))
-
vi.mock('@/app/components/header/account-setting/model-provider-page/provider-added-card/use-trial-credits', () => ({
useTrialCredits: () => ({
isExhausted: false,
@@ -341,7 +334,7 @@ const renderInputVarList = (ui: React.ReactElement) => {
}] as ReturnType['modelProviders'],
})
- return render(
+ return renderWithSystemFeatures(
{ui}
,
diff --git a/web/app/components/workflow/operator/__tests__/zoom-in-out.spec.tsx b/web/app/components/workflow/operator/__tests__/zoom-in-out.spec.tsx
index 8b0f12c7d6..a58bdef727 100644
--- a/web/app/components/workflow/operator/__tests__/zoom-in-out.spec.tsx
+++ b/web/app/components/workflow/operator/__tests__/zoom-in-out.spec.tsx
@@ -1,4 +1,5 @@
-import { fireEvent, render, screen, within } from '@testing-library/react'
+import { fireEvent, screen, within } from '@testing-library/react'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import ZoomInOut from '../zoom-in-out'
const {
@@ -46,18 +47,15 @@ vi.mock('@/app/components/workflow/hooks', () => ({
}),
}))
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_collaboration_mode: boolean } }) => unknown) => selector({
- systemFeatures: {
- enable_collaboration_mode: collaborationEnabled,
- },
- }),
-}))
-
vi.mock('../tip-popup', () => ({
default: ({ children }: { children: React.ReactNode }) => <>{children}>,
}))
+const renderZoomInOut = (ui: React.ReactElement = ) =>
+ renderWithSystemFeatures(ui, {
+ systemFeatures: { enable_collaboration_mode: collaborationEnabled },
+ })
+
const getZoomControls = () => {
const label = Array.from(document.querySelectorAll('button')).find((element) => {
return /^\d+%$/.test(element.textContent ?? '') && element.className.includes('w-[34px]')
@@ -89,7 +87,7 @@ describe('workflow zoom controls', () => {
})
it('zooms out and zooms in when the viewport is within the supported range', () => {
- render( )
+ renderZoomInOut()
const { zoomOutTrigger, zoomInTrigger } = getZoomControls()
@@ -101,7 +99,7 @@ describe('workflow zoom controls', () => {
})
it('zooms to a preset value and syncs the draft', () => {
- render( )
+ renderZoomInOut()
const menu = openZoomMenu()
fireEvent.click(menu.getByText('50%'))
@@ -114,7 +112,7 @@ describe('workflow zoom controls', () => {
['100%', 1],
['200%', 2],
])('zooms to %s and syncs the draft', (label, zoom) => {
- render( )
+ renderZoomInOut()
const menu = openZoomMenu()
fireEvent.click(menu.getByText(label))
@@ -124,7 +122,7 @@ describe('workflow zoom controls', () => {
})
it('toggles collaboration options without syncing the draft', () => {
- render(
+ renderZoomInOut(
{
})
it('keeps the show-user-comments action disabled in comment mode', () => {
- render(
+ renderZoomInOut(
{
it('does not open the menu when the workflow is read only', () => {
workflowReadOnly = true
- render( )
+ renderZoomInOut()
fireEvent.click(getZoomControls().label)
@@ -171,7 +169,7 @@ describe('workflow zoom controls', () => {
it('blocks inline zooming out at the minimum viewport scale', () => {
mockViewport.zoom = 0.25
- render( )
+ renderZoomInOut()
fireEvent.click(getZoomControls().zoomOutTrigger)
expect(mockZoomOut).not.toHaveBeenCalled()
@@ -179,7 +177,7 @@ describe('workflow zoom controls', () => {
it('blocks inline zooming in at the maximum viewport scale', () => {
mockViewport.zoom = 2
- render( )
+ renderZoomInOut()
fireEvent.click(getZoomControls().zoomInTrigger)
expect(mockZoomIn).not.toHaveBeenCalled()
@@ -187,7 +185,7 @@ describe('workflow zoom controls', () => {
it('renders collaboration menu entries only when collaboration is enabled', () => {
collaborationEnabled = false
- render( )
+ renderZoomInOut()
const menu = openZoomMenu()
expect(menu.getByText('workflow.operator.showMiniMap')).toBeInTheDocument()
diff --git a/web/app/components/workflow/operator/zoom-in-out.tsx b/web/app/components/workflow/operator/zoom-in-out.tsx
index 78b07af347..d7f172d2d1 100644
--- a/web/app/components/workflow/operator/zoom-in-out.tsx
+++ b/web/app/components/workflow/operator/zoom-in-out.tsx
@@ -7,6 +7,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
+import { useSuspenseQuery } from '@tanstack/react-query'
import {
Fragment,
memo,
@@ -17,7 +18,7 @@ import {
useReactFlow,
useViewport,
} from 'reactflow'
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import {
useNodesSyncDraft,
useWorkflowReadOnly,
@@ -70,7 +71,10 @@ const ZoomInOut: FC = ({
workflowReadOnly,
getWorkflowReadOnly,
} = useWorkflowReadOnly()
- const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode)
+ const { data: isCollaborationEnabled } = useSuspenseQuery({
+ ...systemFeaturesQueryOptions(),
+ select: s => s.enable_collaboration_mode,
+ })
const zoomOptions = [
[
diff --git a/web/app/components/workflow/run/node.tsx b/web/app/components/workflow/run/node.tsx
index 6f8897e788..3f7e9e9999 100644
--- a/web/app/components/workflow/run/node.tsx
+++ b/web/app/components/workflow/run/node.tsx
@@ -8,6 +8,7 @@ import type {
NodeTracing,
} from '@/types/workflow'
import { cn } from '@langgenius/dify-ui/cn'
+import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import {
RiAlertFill,
RiArrowRightSLine,
@@ -16,9 +17,8 @@ import {
RiLoader2Line,
RiPauseCircleFill,
} from '@remixicon/react'
-import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import Tooltip from '@/app/components/base/tooltip'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import ErrorHandleTip from '@/app/components/workflow/nodes/_base/components/error-handle/error-handle-tip'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
@@ -68,6 +68,16 @@ const NodePanel: FC = ({
return
doSetCollapseState(state)
}, [hideProcessDetail])
+ const titleRef = useRef(null)
+ const [isTooltipOpen, setIsTooltipOpen] = useState(false)
+ const handleTooltipOpenChange = useCallback((open: boolean) => {
+ if (open) {
+ const el = titleRef.current
+ if (!el || el.scrollWidth <= el.clientWidth)
+ return
+ }
+ setIsTooltipOpen(open)
+ }, [])
const { t } = useTranslation()
const docLink = useDocLink()
@@ -132,18 +142,23 @@ const NodePanel: FC = ({
/>
)}
-
+
+ {nodeInfo.title}
+
+ )}
+ />
+
{nodeInfo.title}
- }
- >
-
- {nodeInfo.title}
-
+
{!['running', 'paused'].includes(nodeInfo.status) && !hideInfo && (
diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx
index e89f8ff937..375330bdcb 100644
--- a/web/app/components/workflow/selection-contextmenu.tsx
+++ b/web/app/components/workflow/selection-contextmenu.tsx
@@ -5,11 +5,10 @@ import { cn } from '@langgenius/dify-ui/cn'
import {
ContextMenu,
ContextMenuContent,
- ContextMenuGroup,
ContextMenuItem,
- ContextMenuLabel,
ContextMenuSeparator,
} from '@langgenius/dify-ui/context-menu'
+import { toast } from '@langgenius/dify-ui/toast'
import { produce } from 'immer'
import {
memo,
@@ -19,8 +18,7 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
-import { useStore as useReactFlowStore, useStoreApi } from 'reactflow'
-import { toast } from '@langgenius/dify-ui/toast'
+import { getNodesBounds, useStore as useReactFlowStore, useStoreApi } from 'reactflow'
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
import { useSnippetAndEvaluationPlanAccess } from '@/hooks/use-snippet-and-evaluation-plan-access'
import { useRouter } from '@/next/navigation'
@@ -69,6 +67,7 @@ type ActionMenuItem = {
}
const DEFAULT_SNIPPET_VIEWPORT: SnippetCanvasData['viewport'] = { x: 0, y: 0, zoom: 1 }
+const SNIPPET_VIEWPORT_PADDING = 100
const alignMenuItems: AlignMenuItem[] = [
{ alignType: AlignType.Left, icon: 'i-ri-align-item-left-line', translationKey: 'operator.alignLeft' },
@@ -77,8 +76,8 @@ const alignMenuItems: AlignMenuItem[] = [
{ alignType: AlignType.Top, icon: 'i-ri-align-item-top-line', translationKey: 'operator.alignTop' },
{ alignType: AlignType.Middle, icon: 'i-ri-align-item-vertical-center-line', iconClassName: 'rotate-90', translationKey: 'operator.alignMiddle' },
{ alignType: AlignType.Bottom, icon: 'i-ri-align-item-bottom-line', translationKey: 'operator.alignBottom' },
- { alignType: AlignType.DistributeHorizontal, icon: 'i-ri-align-justify-line', translationKey: 'operator.distributeHorizontal' },
- { alignType: AlignType.DistributeVertical, icon: 'i-ri-align-justify-line', iconClassName: 'rotate-90', translationKey: 'operator.distributeVertical' },
+ { alignType: AlignType.DistributeHorizontal, icon: 'i-custom-vender-line-others-dhs', translationKey: 'operator.distributeHorizontal' },
+ { alignType: AlignType.DistributeVertical, icon: 'i-custom-vender-line-others-dvs', iconClassName: 'rotate-90', translationKey: 'operator.distributeVertical' },
]
const getAlignableNodes = (nodes: Node[], selectedNodes: Node[]) => {
@@ -230,6 +229,10 @@ const getSelectedSnippetGraph = (
nodes: Node[],
edges: Edge[],
selectedNodes: Node[],
+ canvasSize?: {
+ width?: number
+ height?: number
+ },
): SnippetCanvasData => {
const includedNodeIds = new Set(selectedNodes.map(node => node.id))
@@ -259,41 +262,74 @@ const getSelectedSnippetGraph = (
const minRootX = rootNodes.length ? Math.min(...rootNodes.map(node => node.position.x)) : 0
const minRootY = rootNodes.length ? Math.min(...rootNodes.map(node => node.position.y)) : 0
- return {
- nodes: nodes
- .filter(node => includedNodeIds.has(node.id))
- .map((node) => {
- const isRootNode = !node.parentId || !includedNodeIds.has(node.parentId)
- const nextPosition = isRootNode
- ? { x: node.position.x - minRootX, y: node.position.y - minRootY }
- : node.position
+ const snippetNodes = nodes
+ .filter(node => includedNodeIds.has(node.id))
+ .map((node) => {
+ const isRootNode = !node.parentId || !includedNodeIds.has(node.parentId)
+ const nextPosition = isRootNode
+ ? { x: node.position.x - minRootX, y: node.position.y - minRootY }
+ : node.position
- return {
- ...node,
- position: nextPosition,
- positionAbsolute: node.positionAbsolute
- ? (isRootNode
+ return {
+ ...node,
+ position: nextPosition,
+ positionAbsolute: node.positionAbsolute
+ ? (isRootNode
? {
- x: node.positionAbsolute.x - minRootX,
- y: node.positionAbsolute.y - minRootY,
- }
+ x: node.positionAbsolute.x - minRootX,
+ y: node.positionAbsolute.y - minRootY,
+ }
: node.positionAbsolute)
- : undefined,
- selected: false,
- data: {
- ...node.data,
- selected: false,
- _children: node.data._children?.filter(child => includedNodeIds.has(child.nodeId)),
- },
- }
- }),
- edges: edges
- .filter(edge => includedNodeIds.has(edge.source) && includedNodeIds.has(edge.target))
- .map(edge => ({
- ...edge,
+ : undefined,
selected: false,
- })),
- viewport: DEFAULT_SNIPPET_VIEWPORT,
+ data: {
+ ...node.data,
+ selected: false,
+ _children: node.data._children?.filter(child => includedNodeIds.has(child.nodeId)),
+ },
+ }
+ })
+ const snippetEdges = edges
+ .filter(edge => includedNodeIds.has(edge.source) && includedNodeIds.has(edge.target))
+ .map(edge => ({
+ ...edge,
+ selected: false,
+ }))
+
+ const viewportWidth = canvasSize?.width
+ const viewportHeight = canvasSize?.height
+ const hasCanvasSize = !!viewportWidth && !!viewportHeight
+
+ const viewport = (() => {
+ if (!hasCanvasSize || !snippetNodes.length)
+ return DEFAULT_SNIPPET_VIEWPORT
+
+ const bounds = getNodesBounds(snippetNodes)
+ const paddedWidth = bounds.width + SNIPPET_VIEWPORT_PADDING
+ const paddedHeight = bounds.height + SNIPPET_VIEWPORT_PADDING
+ const zoom = Math.min(
+ viewportWidth / paddedWidth,
+ viewportHeight / paddedHeight,
+ 1,
+ )
+
+ if (!Number.isFinite(zoom) || zoom <= 0)
+ return DEFAULT_SNIPPET_VIEWPORT
+
+ const centerX = bounds.x + bounds.width / 2
+ const centerY = bounds.y + bounds.height / 2
+
+ return {
+ x: viewportWidth / 2 - centerX * zoom,
+ y: viewportHeight / 2 - centerY * zoom,
+ zoom,
+ }
+ })()
+
+ return {
+ nodes: snippetNodes,
+ edges: snippetEdges,
+ viewport,
}
}
@@ -337,6 +373,7 @@ const SelectionContextmenu = () => {
}),
}
}, [selectionMenu])
+ const isMenuOpen = Boolean(selectionMenu && anchor)
useEffect(() => {
if (selectionMenu && selectedNodes.length <= 1)
@@ -357,11 +394,18 @@ const SelectionContextmenu = () => {
const nodes = store.getState().getNodes()
const { edges } = store.getState()
+ const {
+ workflowCanvasWidth,
+ workflowCanvasHeight,
+ } = workflowStore.getState()
- setSelectedGraphSnapshot(getSelectedSnippetGraph(nodes, edges, selectedNodes))
+ setSelectedGraphSnapshot(getSelectedSnippetGraph(nodes, edges, selectedNodes, {
+ width: workflowCanvasWidth,
+ height: workflowCanvasHeight,
+ }))
setIsCreateSnippetDialogOpen(true)
handleSelectionContextmenuCancel()
- }, [canAccessSnippetsAndEvaluation, handleSelectionContextmenuCancel, isAddToSnippetDisabled, selectedNodes, store])
+ }, [canAccessSnippetsAndEvaluation, handleSelectionContextmenuCancel, isAddToSnippetDisabled, selectedNodes, store, workflowStore])
const handleCloseCreateSnippetDialog = useCallback(() => {
setIsCreateSnippetDialogOpen(false)
@@ -563,57 +607,59 @@ const SelectionContextmenu = () => {
return (
{
if (!open)
handleSelectionContextmenuCancel()
}}
>
-
-
- {menuActions.map(item => (
- handleMenuAction(item.action)}
- >
- {getActionLabel(item.translationKey)}
- {item.shortcutKeys && (
-
- )}
-
- ))}
-
-
-
-
- {alignMenuItems.map((item) => {
- return (
-
handleAlignNodes(item.alignType)}
- >
-
-
- )
- })}
+ {isMenuOpen && (
+
+
+ {menuActions.map(item => (
+ handleMenuAction(item.action)}
+ >
+ {getActionLabel(item.translationKey)}
+ {item.shortcutKeys && (
+
+ )}
+
+ ))}
-
-
+
+
+
+ {alignMenuItems.map((item) => {
+ return (
+ handleAlignNodes(item.alignType)}
+ >
+
+
+ )
+ })}
+
+
+
+ )}
{isCreateSnippetDialogOpen && (
{
useDocumentTitle('')
const searchParams = useSearchParams()
const token = searchParams.get('token')
- const { systemFeatures } = useGlobalPublicStore()
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
return (
diff --git a/web/app/install/installForm.spec.tsx b/web/app/install/installForm.spec.tsx
index 1286d02343..a9b8cc02be 100644
--- a/web/app/install/installForm.spec.tsx
+++ b/web/app/install/installForm.spec.tsx
@@ -1,9 +1,13 @@
+import type { ReactElement } from 'react'
import type { InitValidateStatusResponse, SetupStatusResponse } from '@/models/common'
-import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { fireEvent, screen, waitFor } from '@testing-library/react'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { fetchInitValidateStatus, fetchSetupStatus, login, setup } from '@/service/common'
import { encryptPassword } from '@/utils/encryption'
import InstallForm from './installForm'
+const render = (ui: ReactElement) => renderWithSystemFeatures(ui)
+
const mockPush = vi.fn()
const mockReplace = vi.fn()
@@ -18,14 +22,6 @@ vi.mock('@/service/common', () => ({
login: vi.fn(),
}))
-vi.mock('@/context/global-public-context', async (importOriginal) => {
- const actual = await importOriginal
()
- return {
- ...actual,
- useIsSystemFeaturesPending: () => false,
- }
-})
-
const mockFetchSetupStatus = vi.mocked(fetchSetupStatus)
const mockFetchInitValidateStatus = vi.mocked(fetchInitValidateStatus)
const mockSetup = vi.mocked(setup)
diff --git a/web/app/install/page.tsx b/web/app/install/page.tsx
index b5d92a3298..8d1f71f817 100644
--- a/web/app/install/page.tsx
+++ b/web/app/install/page.tsx
@@ -1,12 +1,13 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
+import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import Header from '../signin/_header'
import InstallForm from './installForm'
const Install = () => {
- const { systemFeatures } = useGlobalPublicStore()
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
return (
diff --git a/web/app/layout.tsx b/web/app/layout.tsx
index be0c854f02..9fb2b8dae2 100644
--- a/web/app/layout.tsx
+++ b/web/app/layout.tsx
@@ -4,7 +4,7 @@ import { TooltipProvider } from '@langgenius/dify-ui/tooltip'
import { Provider as JotaiProvider } from 'jotai/react'
import { ThemeProvider } from 'next-themes'
import { NuqsAdapter } from 'nuqs/adapters/next/app'
-import GlobalPublicStoreProvider from '@/context/global-public-context'
+import AmplitudeProvider from '@/app/components/base/amplitude'
import { TanstackQueryInitializer } from '@/context/query-client'
import { getDatasetMap } from '@/env'
import { getLocaleOnServer } from '@/i18n-config/server'
@@ -57,6 +57,7 @@ const LocaleLayout = async ({
{...datasetMap}
>
+
-
-
- {children}
-
-
+
+ {children}
+
diff --git a/web/app/loading.tsx b/web/app/loading.tsx
new file mode 100644
index 0000000000..b108baaa97
--- /dev/null
+++ b/web/app/loading.tsx
@@ -0,0 +1,9 @@
+import Loading from '@/app/components/base/loading'
+
+export default function RootLoading() {
+ return (
+
+
+
+ )
+}
diff --git a/web/app/reset-password/layout.tsx b/web/app/reset-password/layout.tsx
index e9909fe438..e2691a397e 100644
--- a/web/app/reset-password/layout.tsx
+++ b/web/app/reset-password/layout.tsx
@@ -1,11 +1,12 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
+import { useSuspenseQuery } from '@tanstack/react-query'
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import Header from '../signin/_header'
export default function SignInLayout({ children }: any) {
- const { systemFeatures } = useGlobalPublicStore()
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
return (
<>
diff --git a/web/app/signin/_header.tsx b/web/app/signin/_header.tsx
index 7ebff9f73c..0b424d45c2 100644
--- a/web/app/signin/_header.tsx
+++ b/web/app/signin/_header.tsx
@@ -1,12 +1,13 @@
'use client'
import type { Locale } from '@/i18n-config'
+import { useSuspenseQuery } from '@tanstack/react-query'
import Divider from '@/app/components/base/divider'
import LocaleSigninSelect from '@/app/components/base/select/locale-signin'
-import { useGlobalPublicStore } from '@/context/global-public-context'
import { useLocale } from '@/context/i18n'
import { setLocaleOnClient } from '@/i18n-config'
import { languages } from '@/i18n-config/language'
import dynamic from '@/next/dynamic'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
// Avoid rendering the logo and theme selector on the server
const DifyLogo = dynamic(() => import('@/app/components/base/logo/dify-logo'), {
@@ -20,7 +21,7 @@ const ThemeSelector = dynamic(() => import('@/app/components/base/theme-selector
const Header = () => {
const locale = useLocale()
- const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
return (
diff --git a/web/app/signin/invite-settings/page.tsx b/web/app/signin/invite-settings/page.tsx
index fe3dac7153..6f198b99ca 100644
--- a/web/app/signin/invite-settings/page.tsx
+++ b/web/app/signin/invite-settings/page.tsx
@@ -3,6 +3,7 @@ import type { Locale } from '@/i18n-config'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import { RiAccountCircleLine } from '@remixicon/react'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { noop } from 'es-toolkit/function'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -10,19 +11,19 @@ import Input from '@/app/components/base/input'
import Loading from '@/app/components/base/loading'
import { SimpleSelect } from '@/app/components/base/select'
import { LICENSE_LINK } from '@/constants/link'
-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 { systemFeaturesQueryOptions } from '@/service/system-features'
import { useInvitationCheck } from '@/service/use-common'
import { timezones } from '@/utils/timezone'
import { resolvePostLoginRedirect } from '../utils/post-login-redirect'
export default function InviteSettingsPage() {
const { t } = useTranslation()
- const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const router = useRouter()
const searchParams = useSearchParams()
const token = decodeURIComponent(searchParams.get('invite_token') as string)
diff --git a/web/app/signin/layout.tsx b/web/app/signin/layout.tsx
index ed32727ae2..a6537793c8 100644
--- a/web/app/signin/layout.tsx
+++ b/web/app/signin/layout.tsx
@@ -1,12 +1,13 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
+import { useSuspenseQuery } from '@tanstack/react-query'
-import { useGlobalPublicStore } from '@/context/global-public-context'
import useDocumentTitle from '@/hooks/use-document-title'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import Header from './_header'
export default function SignInLayout({ children }: any) {
- const { systemFeatures } = useGlobalPublicStore()
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
useDocumentTitle('')
return (
<>
diff --git a/web/app/signin/normal-form.tsx b/web/app/signin/normal-form.tsx
index 2ead90f068..779aba5c9c 100644
--- a/web/app/signin/normal-form.tsx
+++ b/web/app/signin/normal-form.tsx
@@ -1,15 +1,16 @@
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { RiContractLine, RiDoorLockLine, RiErrorWarningFill } from '@remixicon/react'
+import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
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 { systemFeaturesQueryOptions } from '@/service/system-features'
+import { isLegacyBase401, userProfileQueryOptions } from '@/service/use-common'
import { LicenseStatus } from '@/types/feature'
import Loading from '../components/base/loading'
import MailAndCodeAuth from './components/mail-and-code-auth'
@@ -23,14 +24,20 @@ const NormalForm = () => {
const { t } = useTranslation()
const router = useRouter()
const searchParams = useSearchParams()
- const { isLoading: isCheckLoading, data: loginData } = useIsLogin()
- const isLoggedIn = loginData?.logged_in
+ // Login probe: 401 stays as `error` (legitimate "not logged in" state on /signin),
+ // other errors throw to error.tsx. jumpTo same-pathname guard in service/base.ts
+ // prevents the redirect loop on 401.
+ const { isPending: isCheckLoading, data: userResp, error: probeError } = useQuery({
+ ...userProfileQueryOptions(),
+ throwOnError: err => !isLegacyBase401(err),
+ })
+ const isLoggedIn = !!userResp && !probeError
const message = decodeURIComponent(searchParams.get('message') || '')
const invite_token = decodeURIComponent(searchParams.get('invite_token') || '')
const [isInitCheckLoading, setInitCheckLoading] = useState(true)
const [isRedirecting, setIsRedirecting] = useState(false)
const isLoading = isCheckLoading || isInitCheckLoading || isRedirecting
- const { systemFeatures } = useGlobalPublicStore()
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const [authType, updateAuthType] = useState<'code' | 'password'>('password')
const [showORLine, setShowORLine] = useState(false)
const [allMethodsAreDisabled, setAllMethodsAreDisabled] = useState(false)
diff --git a/web/app/signup/components/input-mail.spec.tsx b/web/app/signup/components/input-mail.spec.tsx
index e16c381585..9afea73df7 100644
--- a/web/app/signup/components/input-mail.spec.tsx
+++ b/web/app/signup/components/input-mail.spec.tsx
@@ -1,29 +1,14 @@
import type { MockedFunction } from 'vitest'
-import type { SystemFeatures } from '@/types/feature'
-import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { fireEvent, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
-import { useGlobalPublicStore } from '@/context/global-public-context'
+import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { useLocale } from '@/context/i18n'
import { useSendMail } from '@/service/use-common'
-import { defaultSystemFeatures } from '@/types/feature'
import Form from './input-mail'
const mockSubmitMail = vi.fn()
const mockOnSuccess = vi.fn()
-type SystemFeaturesOverrides = Partial
> & {
- branding?: Partial
-}
-
-const buildSystemFeatures = (overrides: SystemFeaturesOverrides = {}): SystemFeatures => ({
- ...defaultSystemFeatures,
- ...overrides,
- branding: {
- ...defaultSystemFeatures.branding,
- ...overrides.branding,
- },
-})
-
vi.mock('@/next/link', () => ({
default: ({ children, href, className, target, rel }: { children: React.ReactNode, href: string, className?: string, target?: string, rel?: string }) => (
@@ -32,10 +17,6 @@ vi.mock('@/next/link', () => ({
),
}))
-vi.mock('@/context/global-public-context', () => ({
- useGlobalPublicStore: vi.fn(),
-}))
-
vi.mock('@/context/i18n', () => ({
useLocale: vi.fn(),
}))
@@ -46,7 +27,6 @@ vi.mock('@/service/use-common', () => ({
type UseSendMailResult = ReturnType
-const mockUseGlobalPublicStore = useGlobalPublicStore as unknown as MockedFunction
const mockUseLocale = useLocale as unknown as MockedFunction
const mockUseSendMail = useSendMail as unknown as MockedFunction
@@ -57,17 +37,14 @@ const renderForm = ({
brandingEnabled?: boolean
isPending?: boolean
} = {}) => {
- mockUseGlobalPublicStore.mockReturnValue({
- systemFeatures: buildSystemFeatures({
- branding: { enabled: brandingEnabled },
- }),
- })
mockUseLocale.mockReturnValue('en-US')
mockUseSendMail.mockReturnValue({
mutateAsync: mockSubmitMail,
isPending,
} as unknown as UseSendMailResult)
- return render()
+ return renderWithSystemFeatures(, {
+ systemFeatures: { branding: { enabled: brandingEnabled } },
+ })
}
describe('InputMail Form', () => {
diff --git a/web/app/signup/components/input-mail.tsx b/web/app/signup/components/input-mail.tsx
index f4c5214c11..a0a09001d2 100644
--- a/web/app/signup/components/input-mail.tsx
+++ b/web/app/signup/components/input-mail.tsx
@@ -2,14 +2,15 @@
import type { MailSendResponse } from '@/service/use-common'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
+import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import Split from '@/app/signin/split'
import { emailRegex } from '@/config'
-import { useGlobalPublicStore } from '@/context/global-public-context'
import { useLocale } from '@/context/i18n'
import Link from '@/next/link'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useSendMail } from '@/service/use-common'
type Props = {
@@ -21,7 +22,7 @@ export default function Form({
const { t } = useTranslation()
const [email, setEmail] = useState('')
const locale = useLocale()
- const { systemFeatures } = useGlobalPublicStore()
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { mutateAsync: submitMail, isPending } = useSendMail()
diff --git a/web/app/signup/layout.tsx b/web/app/signup/layout.tsx
index 16c5fcefd2..3c1b17ae10 100644
--- a/web/app/signup/layout.tsx
+++ b/web/app/signup/layout.tsx
@@ -1,12 +1,13 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
+import { useSuspenseQuery } from '@tanstack/react-query'
import Header from '@/app/signin/_header'
-import { useGlobalPublicStore } from '@/context/global-public-context'
import useDocumentTitle from '@/hooks/use-document-title'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
export default function RegisterLayout({ children }: any) {
- const { systemFeatures } = useGlobalPublicStore()
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
useDocumentTitle('')
return (
<>
diff --git a/web/context/app-context-provider.tsx b/web/context/app-context-provider.tsx
index 0bf3851046..fb17664d6d 100644
--- a/web/context/app-context-provider.tsx
+++ b/web/context/app-context-provider.tsx
@@ -2,7 +2,7 @@
import type { FC, ReactNode } from 'react'
import type { ICurrentWorkspace, LangGeniusVersionResponse, UserProfileResponse } from '@/models/common'
-import { useQueryClient } from '@tanstack/react-query'
+import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo } from 'react'
import { setUserId, setUserProperties } from '@/app/components/base/amplitude'
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
@@ -16,12 +16,12 @@ import {
useSelector,
} from '@/context/app-context'
import { env } from '@/env'
+import { systemFeaturesQueryOptions } from '@/service/system-features'
import {
useCurrentWorkspace,
useLangGeniusVersion,
- useUserProfile,
+ userProfileQueryOptions,
} from '@/service/use-common'
-import { useGlobalPublicStore } from './global-public-context'
type AppContextProviderProps = {
children: ReactNode
@@ -29,8 +29,13 @@ type AppContextProviderProps = {
export const AppContextProvider: FC = ({ children }) => {
const queryClient = useQueryClient()
- const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
- const { data: userProfileResp } = useUserProfile()
+ // Boot point for the (commonLayout) tree:
+ // - useSuspenseQuery for systemFeatures triggers app/loading.tsx until cache is warm.
+ // - useSuspenseQuery for userProfile triggers (commonLayout)/loading.tsx until cache is warm.
+ // After this provider mounts, downstream components reading the same queryKeys hit cache
+ // and never suspend again, so their useSuspenseQuery calls return data synchronously.
+ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
+ const { data: userProfileResp } = useSuspenseQuery(userProfileQueryOptions())
const { data: currentWorkspaceResp, isPending: isLoadingCurrentWorkspace, isFetching: isValidatingCurrentWorkspace } = useCurrentWorkspace()
const langGeniusVersionQuery = useLangGeniusVersion(
userProfileResp?.meta.currentVersion,
diff --git a/web/context/global-public-context.tsx b/web/context/global-public-context.tsx
deleted file mode 100644
index 190033bbf7..0000000000
--- a/web/context/global-public-context.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-'use client'
-import type { FC, PropsWithChildren } from 'react'
-import type { SystemFeatures } from '@/types/feature'
-import { useQuery } from '@tanstack/react-query'
-import { create } from 'zustand'
-import Loading from '@/app/components/base/loading'
-import { consoleClient } from '@/service/client'
-import { defaultSystemFeatures } from '@/types/feature'
-import { fetchSetupStatusWithCache } from '@/utils/setup-status'
-
-type GlobalPublicStore = {
- systemFeatures: SystemFeatures
- setSystemFeatures: (systemFeatures: SystemFeatures) => void
-}
-
-export const useGlobalPublicStore = create(set => ({
- systemFeatures: defaultSystemFeatures,
- setSystemFeatures: (systemFeatures: SystemFeatures) => set(() => ({ systemFeatures })),
-}))
-
-const systemFeaturesQueryKey = ['systemFeatures'] as const
-const setupStatusQueryKey = ['setupStatus'] as const
-
-async function fetchSystemFeatures() {
- const data = await consoleClient.systemFeatures()
- const { setSystemFeatures } = useGlobalPublicStore.getState()
- setSystemFeatures({ ...defaultSystemFeatures, ...data })
- return data
-}
-
-export function useSystemFeaturesQuery() {
- return useQuery({
- queryKey: systemFeaturesQueryKey,
- queryFn: fetchSystemFeatures,
- })
-}
-
-export function useIsSystemFeaturesPending() {
- const { isPending } = useSystemFeaturesQuery()
- return isPending
-}
-
-function useSetupStatusQuery() {
- return useQuery({
- queryKey: setupStatusQueryKey,
- queryFn: fetchSetupStatusWithCache,
- staleTime: Infinity,
- })
-}
-
-const GlobalPublicStoreProvider: FC = ({
- children,
-}) => {
- // Fetch systemFeatures and setupStatus in parallel to reduce waterfall.
- // setupStatus is prefetched here and cached in localStorage for AppInitializer.
- const { isPending } = useSystemFeaturesQuery()
-
- // Prefetch setupStatus for AppInitializer (result not needed here)
- useSetupStatusQuery()
-
- if (isPending)
- return