diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 2d53d97dd0..e16a1be0f7 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -59,7 +59,7 @@ }, "web/__tests__/embedded-user-id-store.test.tsx": { "ts/no-explicit-any": { - "count": 3 + "count": 1 } }, "web/__tests__/goto-anything/command-selector.test.tsx": { @@ -6399,11 +6399,6 @@ "count": 1 } }, - "web/context/global-public-context.tsx": { - "react-refresh/only-export-components": { - "count": 3 - } - }, "web/context/hooks/use-trigger-events-limit-modal.ts": { "react/set-state-in-effect": { "count": 3 diff --git a/web/__tests__/app/app-access-control-flow.test.tsx b/web/__tests__/app/app-access-control-flow.test.tsx index 63f7fd0378..e1284bfc5b 100644 --- a/web/__tests__/app/app-access-control-flow.test.tsx +++ b/web/__tests__/app/app-access-control-flow.test.tsx @@ -1,6 +1,6 @@ -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 { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import AppPublisher from '@/app/components/app/app-publisher' import { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' @@ -24,27 +24,15 @@ let mockAppDetail: { } } | null = null -const createTestQueryClient = () => - new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - mutations: { - retry: false, +const renderWithQueryClient = (ui: React.ReactElement) => + renderWithSystemFeatures(ui, { + systemFeatures: { + webapp_auth: { + enabled: true, }, }, }) -const renderWithQueryClient = (ui: React.ReactElement) => { - const queryClient = createTestQueryClient() - return render( - - {ui} - , - ) -} - vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key, @@ -58,16 +46,6 @@ vi.mock('@/app/components/app/store', () => ({ }), })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector: (state: Record) => unknown) => selector({ - systemFeatures: { - webapp_auth: { - enabled: true, - }, - }, - }), -})) - vi.mock('@/hooks/use-format-time-from-now', () => ({ useFormatTimeFromNow: () => ({ formatTimeFromNow: (value: number) => `ago:${value}`, diff --git a/web/__tests__/app/app-publisher-flow.test.tsx b/web/__tests__/app/app-publisher-flow.test.tsx index 9c09acf6a1..d4bf56e7e4 100644 --- a/web/__tests__/app/app-publisher-flow.test.tsx +++ b/web/__tests__/app/app-publisher-flow.test.tsx @@ -1,6 +1,6 @@ -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 { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import AppPublisher from '@/app/components/app/app-publisher' import { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' @@ -28,27 +28,15 @@ let mockAppDetail: { } } | null = null -const createTestQueryClient = () => - new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - mutations: { - retry: false, +const renderWithQueryClient = (ui: React.ReactElement) => + renderWithSystemFeatures(ui, { + systemFeatures: { + webapp_auth: { + enabled: true, }, }, }) -const renderWithQueryClient = (ui: React.ReactElement) => { - const queryClient = createTestQueryClient() - return render( - - {ui} - , - ) -} - vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, @@ -66,16 +54,6 @@ vi.mock('@/app/components/app/store', () => ({ }), })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector: (state: Record) => unknown) => selector({ - systemFeatures: { - webapp_auth: { - enabled: true, - }, - }, - }), -})) - vi.mock('@/hooks/use-format-time-from-now', () => ({ useFormatTimeFromNow: () => ({ formatTimeFromNow: (value: number) => `ago:${value}`, diff --git a/web/__tests__/apps/app-card-operations-flow.test.tsx b/web/__tests__/apps/app-card-operations-flow.test.tsx index 8c3219794d..b0854072d2 100644 --- a/web/__tests__/apps/app-card-operations-flow.test.tsx +++ b/web/__tests__/apps/app-card-operations-flow.test.tsx @@ -10,8 +10,9 @@ * - Access mode icons */ import type { App } from '@/types/app' -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 AppCard from '@/app/components/apps/app-card' import { AccessMode } from '@/models/access-control' import { exportAppConfig, updateAppInfo } from '@/service/apps' @@ -96,15 +97,6 @@ vi.mock('@/context/app-context', () => ({ }), })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector?: (state: Record) => unknown) => { - const state = { systemFeatures: mockSystemFeatures } - if (typeof selector === 'function') - return selector(state) - return mockSystemFeatures - }, -})) - vi.mock('@/context/provider-context', () => ({ useProviderContext: () => ({ onPlanInfoChanged: mockOnPlanInfoChanged, @@ -255,7 +247,10 @@ const createMockApp = (overrides: Partial = {}): App => ({ const mockOnRefresh = vi.fn() const renderAppCard = (app?: Partial) => { - return render() + return renderWithSystemFeatures( + , + { systemFeatures: mockSystemFeatures }, + ) } const openOperationsMenu = () => { diff --git a/web/__tests__/apps/app-list-browsing-flow.test.tsx b/web/__tests__/apps/app-list-browsing-flow.test.tsx index a5ed79a7bd..768420f00d 100644 --- a/web/__tests__/apps/app-list-browsing-flow.test.tsx +++ b/web/__tests__/apps/app-list-browsing-flow.test.tsx @@ -1,3 +1,4 @@ +import type { ReactElement, ReactNode } from 'react' /** * Integration test: App List Browsing Flow * @@ -8,11 +9,12 @@ */ import type { AppListResponse } from '@/models/app' import type { App } from '@/types/app' -import { fireEvent, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features' import List from '@/app/components/apps/list' import { AccessMode } from '@/models/access-control' -import { renderWithNuqs } from '@/test/nuqs-testing' +import { createNuqsTestWrapper } from '@/test/nuqs-testing' import { AppModeEnum } from '@/types/app' let mockIsCurrentWorkspaceEditor = true @@ -64,13 +66,6 @@ vi.mock('@/context/app-context', () => ({ }), })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector?: (state: Record) => unknown) => { - const state = { systemFeatures: mockSystemFeatures } - return selector ? selector(state) : state - }, -})) - vi.mock('@/context/provider-context', () => ({ useProviderContext: () => ({ onPlanInfoChanged: vi.fn(), @@ -168,11 +163,21 @@ const createPage = (apps: App[], hasMore = false, page = 1): AppListResponse => total: apps.length, }) -const renderList = (searchParams?: Record) => { - return renderWithNuqs( - , - { searchParams }, +const renderListUI = (ui: ReactElement, searchParams?: Record) => { + const { wrapper: SysWrapper } = createSystemFeaturesWrapper({ + systemFeatures: mockSystemFeatures, + }) + const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams }) + const Wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + ) + return { ...render(ui, { wrapper: Wrapper }), onUrlUpdate } +} + +const renderList = (searchParams?: Record) => { + return renderListUI(, searchParams) } describe('App List Browsing Flow', () => { @@ -216,7 +221,7 @@ describe('App List Browsing Flow', () => { it('should transition from loading to content when data loads', () => { mockIsLoading = true - const { rerender } = renderWithNuqs() + const { rerender } = renderListUI() const skeletonCards = document.querySelectorAll('.animate-pulse') expect(skeletonCards.length).toBeGreaterThan(0) @@ -423,7 +428,7 @@ describe('App List Browsing Flow', () => { it('should call refetch when controlRefreshList increments', () => { mockPages = [createPage([createMockApp()])] - const { rerender } = renderWithNuqs() + const { rerender } = renderListUI() rerender() diff --git a/web/__tests__/apps/create-app-flow.test.tsx b/web/__tests__/apps/create-app-flow.test.tsx index 9abc870ecf..e480db06ea 100644 --- a/web/__tests__/apps/create-app-flow.test.tsx +++ b/web/__tests__/apps/create-app-flow.test.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react' /** * Integration test: Create App Flow * @@ -9,11 +10,12 @@ */ import type { AppListResponse } from '@/models/app' import type { App } from '@/types/app' -import { fireEvent, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features' import List from '@/app/components/apps/list' import { AccessMode } from '@/models/access-control' -import { renderWithNuqs } from '@/test/nuqs-testing' +import { createNuqsTestWrapper } from '@/test/nuqs-testing' import { AppModeEnum } from '@/types/app' let mockIsCurrentWorkspaceEditor = true @@ -51,13 +53,6 @@ vi.mock('@/context/app-context', () => ({ }), })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector?: (state: Record) => unknown) => { - const state = { systemFeatures: mockSystemFeatures } - return selector ? selector(state) : state - }, -})) - vi.mock('@/context/provider-context', () => ({ useProviderContext: () => ({ onPlanInfoChanged: mockOnPlanInfoChanged, @@ -222,7 +217,16 @@ const createPage = (apps: App[]): AppListResponse => ({ }) const renderList = () => { - return renderWithNuqs() + const { wrapper: SysWrapper } = createSystemFeaturesWrapper({ + systemFeatures: mockSystemFeatures, + }) + const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper() + const Wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + return { ...render(, { wrapper: Wrapper }), onUrlUpdate } } describe('Create App Flow', () => { diff --git a/web/__tests__/base/chat-flow.test.tsx b/web/__tests__/base/chat-flow.test.tsx index 2a02c063fd..6ede7c766b 100644 --- a/web/__tests__/base/chat-flow.test.tsx +++ b/web/__tests__/base/chat-flow.test.tsx @@ -1,8 +1,9 @@ import type { RefObject } from 'react' import type { ChatConfig } from '@/app/components/base/chat/types' import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share' -import { fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react' +import { fireEvent, renderHook, 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 ChatWithHistory from '@/app/components/base/chat/chat-with-history' import { useChatWithHistory } from '@/app/components/base/chat/chat-with-history/hooks' import { useThemeContext } from '@/app/components/base/chat/embedded-chatbot/theme/theme-context' diff --git a/web/__tests__/embedded-user-id-store.test.tsx b/web/__tests__/embedded-user-id-store.test.tsx index 04597ccfeb..7e9eec644b 100644 --- a/web/__tests__/embedded-user-id-store.test.tsx +++ b/web/__tests__/embedded-user-id-store.test.tsx @@ -1,5 +1,6 @@ -import { render, screen, waitFor } from '@testing-library/react' +import { screen, waitFor } from '@testing-library/react' import * as React from 'react' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import WebAppStoreProvider, { useWebAppStore } from '@/context/web-app-context' import { AccessMode } from '@/models/access-control' @@ -19,44 +20,12 @@ vi.mock('@/service/use-share', () => ({ })), })) -// Store the mock implementation in a way that survives hoisting const mockGetProcessedSystemVariablesFromUrlParams = vi.fn() vi.mock('@/app/components/base/chat/utils', () => ({ getProcessedSystemVariablesFromUrlParams: (...args: any[]) => mockGetProcessedSystemVariablesFromUrlParams(...args), })) -// Use vi.hoisted to define mock state before vi.mock hoisting -const { mockGlobalStoreState } = vi.hoisted(() => ({ - mockGlobalStoreState: { - isGlobalPending: false, - setIsGlobalPending: vi.fn(), - systemFeatures: {}, - setSystemFeatures: vi.fn(), - }, -})) - -vi.mock('@/context/global-public-context', () => { - const useGlobalPublicStore = Object.assign( - (selector?: (state: typeof mockGlobalStoreState) => any) => - selector ? selector(mockGlobalStoreState) : mockGlobalStoreState, - { - setState: (updater: any) => { - if (typeof updater === 'function') - Object.assign(mockGlobalStoreState, updater(mockGlobalStoreState) ?? {}) - - else - Object.assign(mockGlobalStoreState, updater) - }, - __mockState: mockGlobalStoreState, - }, - ) - return { - useGlobalPublicStore, - useIsSystemFeaturesPending: () => false, - } -}) - const TestConsumer = () => { const embeddedUserId = useWebAppStore(state => state.embeddedUserId) const embeddedConversationId = useWebAppStore(state => state.embeddedConversationId) @@ -91,7 +60,6 @@ const initialWebAppStore = (() => { })() beforeEach(() => { - mockGlobalStoreState.isGlobalPending = false mockGetProcessedSystemVariablesFromUrlParams.mockReset() useWebAppStore.setState(initialWebAppStore, true) }) @@ -103,7 +71,7 @@ describe('WebAppStoreProvider embedded user id handling', () => { conversation_id: 'conversation-456', }) - render( + renderWithSystemFeatures( , @@ -125,7 +93,7 @@ describe('WebAppStoreProvider embedded user id handling', () => { })) mockGetProcessedSystemVariablesFromUrlParams.mockResolvedValue({}) - render( + renderWithSystemFeatures( , diff --git a/web/__tests__/explore/explore-app-list-flow.test.tsx b/web/__tests__/explore/explore-app-list-flow.test.tsx index e2c7831018..6af17119be 100644 --- a/web/__tests__/explore/explore-app-list-flow.test.tsx +++ b/web/__tests__/explore/explore-app-list-flow.test.tsx @@ -7,7 +7,8 @@ import type { Mock } from 'vitest' import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal' import type { App } from '@/models/explore' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features' import AppList from '@/app/components/explore/app-list' import { useAppContext } from '@/context/app-context' import { fetchAppDetail } from '@/service/explore' diff --git a/web/__tests__/header/account-dropdown-flow.test.tsx b/web/__tests__/header/account-dropdown-flow.test.tsx index 6a645c7a43..b4a3befea0 100644 --- a/web/__tests__/header/account-dropdown-flow.test.tsx +++ b/web/__tests__/header/account-dropdown-flow.test.tsx @@ -1,6 +1,6 @@ -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 { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { Plan } from '@/app/components/billing/type' import AccountDropdown from '@/app/components/header/account-dropdown' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' @@ -52,20 +52,6 @@ vi.mock('@/context/provider-context', () => ({ }), })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector?: (state: Record) => unknown) => { - const state = { - systemFeatures: { - branding: { - enabled: false, - workspace_logo: null, - }, - }, - } - return selector ? selector(state) : state - }, -})) - vi.mock('@/context/modal-context', () => ({ useModalContext: () => ({ setShowAccountSettingModal: mockSetShowAccountSettingModal, @@ -108,18 +94,14 @@ vi.mock('@/next/link', () => ({ })) const renderAccountDropdown = () => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, + return renderWithSystemFeatures(, { + systemFeatures: { + branding: { + enabled: false, + workspace_logo: '', + }, }, }) - - return render( - - - , - ) } describe('Header Account Dropdown Flow', () => { diff --git a/web/__tests__/plugins/plugin-marketplace-to-install.test.tsx b/web/__tests__/plugins/plugin-marketplace-to-install.test.tsx index 91e32155e7..3d08fe9d7c 100644 --- a/web/__tests__/plugins/plugin-marketplace-to-install.test.tsx +++ b/web/__tests__/plugins/plugin-marketplace-to-install.test.tsx @@ -1,16 +1,7 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { pluginInstallLimit } from '@/app/components/plugins/install-plugin/hooks/use-install-plugin-limit' import { InstallationScope } from '@/types/feature' -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: () => ({ - plugin_installation_permission: { - restrict_to_marketplace_only: false, - plugin_installation_scope: InstallationScope.ALL, - }, - }), -})) - describe('Plugin Marketplace to Install Flow', () => { describe('install permission validation pipeline', () => { const systemFeaturesAll = { diff --git a/web/__tests__/plugins/plugin-page-shell-flow.test.tsx b/web/__tests__/plugins/plugin-page-shell-flow.test.tsx index 9202f647af..bd089d325c 100644 --- a/web/__tests__/plugins/plugin-page-shell-flow.test.tsx +++ b/web/__tests__/plugins/plugin-page-shell-flow.test.tsx @@ -1,7 +1,9 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features' import PluginPage from '@/app/components/plugins/plugin-page' -import { renderWithNuqs } from '@/test/nuqs-testing' +import { createNuqsTestWrapper } from '@/test/nuqs-testing' const mockFetchManifestFromMarketPlace = vi.fn() @@ -35,17 +37,6 @@ vi.mock('@/context/app-context', () => ({ }), })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector: (state: Record) => unknown) => selector({ - systemFeatures: { - enable_marketplace: true, - plugin_installation_permission: { - restrict_to_marketplace_only: false, - }, - }, - }), -})) - vi.mock('@/service/use-plugins', () => ({ useReferenceSettings: () => ({ data: { @@ -104,13 +95,30 @@ vi.mock('@/app/components/plugins/install-plugin/install-from-marketplace', () = })) const renderPluginPage = (searchParams = '') => { - return renderWithNuqs( - plugins view} - marketplace={
marketplace view
} - />, - { searchParams }, + const { wrapper: SysWrapper } = createSystemFeaturesWrapper({ + systemFeatures: { + enable_marketplace: true, + plugin_installation_permission: { + restrict_to_marketplace_only: false, + }, + }, + }) + const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams }) + const Wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + ) + return { + ...render( + plugins view} + marketplace={
marketplace view
} + />, + { wrapper: Wrapper }, + ), + onUrlUpdate, + } } describe('Plugin Page Shell Flow', () => { diff --git a/web/__tests__/share/text-generation-index-flow.test.tsx b/web/__tests__/share/text-generation-index-flow.test.tsx index 2fec054a47..638f774c16 100644 --- a/web/__tests__/share/text-generation-index-flow.test.tsx +++ b/web/__tests__/share/text-generation-index-flow.test.tsx @@ -1,6 +1,7 @@ import type { AccessMode } from '@/models/access-control' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import * as React from 'react' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import TextGeneration from '@/app/components/share/text-generation' const useSearchParamsMock = vi.fn(() => new URLSearchParams()) @@ -117,7 +118,7 @@ vi.mock('@/service/share', async () => { const mockSystemFeatures = { branding: { enabled: false, - workspace_logo: null, + workspace_logo: '', }, } @@ -170,11 +171,6 @@ const mockWebAppState = { webAppAccessMode: 'public' as AccessMode, } -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), })) @@ -189,7 +185,7 @@ describe('TextGeneration', () => { }) it('should switch between create, batch, and saved tabs after app state loads', async () => { - render() + renderWithSystemFeatures(, { systemFeatures: mockSystemFeatures }) await waitFor(() => { expect(screen.getByTestId('run-once-mock')).toBeInTheDocument() @@ -212,7 +208,7 @@ describe('TextGeneration', () => { }) it('should wire single-run stop control and clear it when batch execution starts', async () => { - render() + renderWithSystemFeatures(, { systemFeatures: mockSystemFeatures }) await waitFor(() => { expect(screen.getByTestId('run-once-mock')).toBeInTheDocument() diff --git a/web/__tests__/tools/provider-list-shell-flow.test.tsx b/web/__tests__/tools/provider-list-shell-flow.test.tsx index d0d096f072..afa3f45e9f 100644 --- a/web/__tests__/tools/provider-list-shell-flow.test.tsx +++ b/web/__tests__/tools/provider-list-shell-flow.test.tsx @@ -1,8 +1,10 @@ -import { fireEvent, screen, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features' import ProviderList from '@/app/components/tools/provider-list' import { CollectionType } from '@/app/components/tools/types' -import { renderWithNuqs } from '@/test/nuqs-testing' +import { createNuqsTestWrapper } from '@/test/nuqs-testing' const mockInvalidateInstalledPluginList = vi.fn() @@ -12,14 +14,6 @@ vi.mock('react-i18next', () => ({ }), })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector: (state: Record) => unknown) => selector({ - systemFeatures: { - enable_marketplace: true, - }, - }), -})) - vi.mock('@/app/components/plugins/hooks', () => ({ useTags: () => ({ getTagLabel: (name: string) => name, @@ -159,7 +153,16 @@ vi.mock('@/app/components/tools/mcp', () => ({ })) const renderProviderList = (searchParams = '') => { - return renderWithNuqs(, { searchParams }) + const { wrapper: SysWrapper } = createSystemFeaturesWrapper({ + systemFeatures: { enable_marketplace: true }, + }) + const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper({ searchParams }) + const Wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ) + return { ...render(, { wrapper: Wrapper }), onUrlUpdate } } describe('Tool Provider List Shell Flow', () => { diff --git a/web/__tests__/tools/tool-browsing-and-filtering.test.tsx b/web/__tests__/tools/tool-browsing-and-filtering.test.tsx index aa8f59ca31..b1cc0c1312 100644 --- a/web/__tests__/tools/tool-browsing-and-filtering.test.tsx +++ b/web/__tests__/tools/tool-browsing-and-filtering.test.tsx @@ -6,10 +6,10 @@ import type { Collection } from '@/app/components/tools/types' * Input (search), and card rendering. Verifies that tab switching, keyword * filtering, and label filtering work together correctly. */ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features' import { CollectionType } from '@/app/components/tools/types' // ---- Mocks ---- @@ -36,10 +36,6 @@ vi.mock('nuqs', async (importOriginal) => { } }) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: () => ({ enable_marketplace: false }), -})) - vi.mock('@/app/components/plugins/hooks', () => ({ useTags: () => ({ getTagLabel: (key: string) => key, @@ -237,12 +233,10 @@ vi.mock('@/app/components/workflow/block-selector/types', () => ({ const { default: ProviderList } = await import('@/app/components/tools/provider-list') const createWrapper = () => { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, + const { wrapper } = createSystemFeaturesWrapper({ + systemFeatures: { enable_marketplace: false }, }) - return ({ children }: { children: React.ReactNode }) => ( - {children} - ) + return wrapper } describe('Tool Browsing & Filtering Integration', () => { diff --git a/web/__tests__/utils/mock-system-features.tsx b/web/__tests__/utils/mock-system-features.tsx new file mode 100644 index 0000000000..6884e68237 --- /dev/null +++ b/web/__tests__/utils/mock-system-features.tsx @@ -0,0 +1,127 @@ +import type { RenderHookOptions, RenderHookResult, RenderOptions, RenderResult } from '@testing-library/react' +import type { ReactElement, ReactNode } from 'react' +import type { SystemFeatures } from '@/types/feature' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, renderHook } from '@testing-library/react' +import { consoleQuery } from '@/service/client' +import { defaultSystemFeatures } from '@/types/feature' + +type DeepPartial = T extends Array + ? Array + : T extends object + ? { [K in keyof T]?: DeepPartial } + : T + +const buildSystemFeatures = ( + overrides: DeepPartial = {}, +): SystemFeatures => { + const o = overrides as Partial + return { + ...defaultSystemFeatures, + ...o, + branding: { + ...defaultSystemFeatures.branding, + ...(o.branding ?? {}), + }, + webapp_auth: { + ...defaultSystemFeatures.webapp_auth, + ...(o.webapp_auth ?? {}), + sso_config: { + ...defaultSystemFeatures.webapp_auth.sso_config, + ...(o.webapp_auth?.sso_config ?? {}), + }, + }, + plugin_installation_permission: { + ...defaultSystemFeatures.plugin_installation_permission, + ...(o.plugin_installation_permission ?? {}), + }, + license: { + ...defaultSystemFeatures.license, + ...(o.license ?? {}), + }, + } +} + +/** + * Build a QueryClient suitable for tests. Any unseeded query stays in the + * "pending" state forever because the default queryFn never resolves; this + * mirrors the behaviour of an in-flight network request without touching the + * real fetch layer. + */ +export const createTestQueryClient = (): QueryClient => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + staleTime: Infinity, + queryFn: () => new Promise(() => {}), + }, + mutations: { retry: false }, + }, + }) + +export const seedSystemFeatures = ( + queryClient: QueryClient, + overrides: DeepPartial = {}, +): SystemFeatures => { + const data = buildSystemFeatures(overrides) + queryClient.setQueryData(consoleQuery.systemFeatures.queryKey(), data) + return data +} + +type SystemFeaturesTestOptions = { + /** + * Partial overrides for the systemFeatures payload. When omitted, the cache + * is seeded with `defaultSystemFeatures` so consumers using + * `useSuspenseQuery` resolve immediately. Pass `null` to skip seeding and + * keep the systemFeatures query in the pending state. + */ + systemFeatures?: DeepPartial | null + queryClient?: QueryClient +} + +type SystemFeaturesWrapper = { + queryClient: QueryClient + systemFeatures: SystemFeatures | null + wrapper: (props: { children: ReactNode }) => ReactElement +} + +export const createSystemFeaturesWrapper = ( + options: SystemFeaturesTestOptions = {}, +): SystemFeaturesWrapper => { + const queryClient = options.queryClient ?? createTestQueryClient() + const systemFeatures = options.systemFeatures === null + ? null + : seedSystemFeatures(queryClient, options.systemFeatures) + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + return { queryClient, systemFeatures, wrapper } +} + +export const renderWithSystemFeatures = ( + ui: ReactElement, + options: SystemFeaturesTestOptions & Omit = {}, +): RenderResult & { queryClient: QueryClient, systemFeatures: SystemFeatures | null } => { + const { systemFeatures: sf, queryClient: qc, ...renderOptions } = options + const { wrapper, queryClient, systemFeatures } = createSystemFeaturesWrapper({ + systemFeatures: sf, + queryClient: qc, + }) + const rendered = render(ui, { wrapper, ...renderOptions }) + return { ...rendered, queryClient, systemFeatures } +} + +export const renderHookWithSystemFeatures = ( + callback: (props: Props) => Result, + options: SystemFeaturesTestOptions & Omit, 'wrapper'> = {}, +): RenderHookResult & { queryClient: QueryClient, systemFeatures: SystemFeatures | null } => { + const { systemFeatures: sf, queryClient: qc, ...hookOptions } = options + const { wrapper, queryClient, systemFeatures } = createSystemFeaturesWrapper({ + systemFeatures: sf, + queryClient: qc, + }) + const rendered = renderHook(callback, { wrapper, ...hookOptions }) + return { ...rendered, queryClient, systemFeatures } +} diff --git a/web/app/(commonLayout)/error.tsx b/web/app/(commonLayout)/error.tsx new file mode 100644 index 0000000000..dbc5ded3e9 --- /dev/null +++ b/web/app/(commonLayout)/error.tsx @@ -0,0 +1,33 @@ +'use client' + +import { Button } from '@langgenius/dify-ui/button' +import { useTranslation } from 'react-i18next' +import RootLoading from '@/app/loading' +import { isLegacyBase401 } from '@/service/use-common' + +type Props = { + error: Error & { digest?: string } + unstable_retry: () => void +} + +export default function CommonLayoutError({ error, unstable_retry }: Props) { + const { t } = useTranslation('common') + + // 401 already triggered jumpTo(/signin) inside service/base.ts. Render Loading + // until the browser navigation completes, matching main's Splash behavior. + // Showing the "Try again" button here would just flash for a few frames before + // the page navigates away, and clicking it would 401 again anyway. + if (isLegacyBase401(error)) + return + + return ( +
+
+ {t('errorBoundary.message')} +
+ +
+ ) +} diff --git a/web/app/(commonLayout)/layout.tsx b/web/app/(commonLayout)/layout.tsx index 35da7ef792..2467f35b7b 100644 --- a/web/app/(commonLayout)/layout.tsx +++ b/web/app/(commonLayout)/layout.tsx @@ -14,7 +14,6 @@ import { EventEmitterContextProvider } from '@/context/event-emitter-provider' import { ModalContextProvider } from '@/context/modal-context-provider' import { ProviderContextProvider } from '@/context/provider-context-provider' import PartnerStack from '../components/billing/partner-stack' -import Splash from '../components/splash' import RoleRouteGuard from './role-route-guard' const Layout = ({ children }: { children: ReactNode }) => { @@ -37,7 +36,6 @@ const Layout = ({ children }: { children: ReactNode }) => { - diff --git a/web/app/(commonLayout)/loading.tsx b/web/app/(commonLayout)/loading.tsx new file mode 100644 index 0000000000..3a5a14dc25 --- /dev/null +++ b/web/app/(commonLayout)/loading.tsx @@ -0,0 +1,9 @@ +import Loading from '@/app/components/base/loading' + +export default function CommonLayoutLoading() { + return ( +
+ +
+ ) +} diff --git a/web/app/(shareLayout)/webapp-reset-password/layout.tsx b/web/app/(shareLayout)/webapp-reset-password/layout.tsx index 1a035bfb44..82749c8641 100644 --- a/web/app/(shareLayout)/webapp-reset-password/layout.tsx +++ b/web/app/(shareLayout)/webapp-reset-password/layout.tsx @@ -1,11 +1,12 @@ '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 { systemFeaturesQueryOptions } from '@/service/system-features' export default function SignInLayout({ children }: any) { - const { systemFeatures } = useGlobalPublicStore() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) return ( <>
diff --git a/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx b/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx index fe6b157c1e..003aab4cab 100644 --- a/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx +++ b/web/app/(shareLayout)/webapp-signin/components/external-member-sso-auth.tsx @@ -1,16 +1,17 @@ 'use client' import { toast } from '@langgenius/dify-ui/toast' +import { useSuspenseQuery } from '@tanstack/react-query' import * as React from 'react' import { useCallback, useEffect } from 'react' import AppUnavailable from '@/app/components/base/app-unavailable' import Loading from '@/app/components/base/loading' -import { useGlobalPublicStore } from '@/context/global-public-context' import { useRouter, useSearchParams } from '@/next/navigation' import { fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share' +import { systemFeaturesQueryOptions } from '@/service/system-features' import { SSOProtocol } from '@/types/feature' const ExternalMemberSSOAuth = () => { - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const searchParams = useSearchParams() const router = useRouter() diff --git a/web/app/(shareLayout)/webapp-signin/layout.tsx b/web/app/(shareLayout)/webapp-signin/layout.tsx index 5451b45194..99dd787cec 100644 --- a/web/app/(shareLayout)/webapp-signin/layout.tsx +++ b/web/app/(shareLayout)/webapp-signin/layout.tsx @@ -2,13 +2,14 @@ import type { PropsWithChildren } from 'react' import { cn } from '@langgenius/dify-ui/cn' +import { useSuspenseQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' -import { useGlobalPublicStore } from '@/context/global-public-context' import useDocumentTitle from '@/hooks/use-document-title' +import { systemFeaturesQueryOptions } from '@/service/system-features' export default function SignInLayout({ children }: PropsWithChildren) { const { t } = useTranslation() - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) useDocumentTitle(t('webapp.login', { ns: 'login' })) return ( <> diff --git a/web/app/(shareLayout)/webapp-signin/normalForm.tsx b/web/app/(shareLayout)/webapp-signin/normalForm.tsx index 436c7e64bb..9b7b64eac5 100644 --- a/web/app/(shareLayout)/webapp-signin/normalForm.tsx +++ b/web/app/(shareLayout)/webapp-signin/normalForm.tsx @@ -1,13 +1,14 @@ 'use client' import { cn } from '@langgenius/dify-ui/cn' import { RiContractLine, RiDoorLockLine, RiErrorWarningFill } from '@remixicon/react' +import { useSuspenseQuery } from '@tanstack/react-query' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import { IS_CE_EDITION } from '@/config' -import { useGlobalPublicStore } from '@/context/global-public-context' import Link from '@/next/link' +import { systemFeaturesQueryOptions } from '@/service/system-features' import { LicenseStatus } from '@/types/feature' import MailAndCodeAuth from './components/mail-and-code-auth' import MailAndPasswordAuth from './components/mail-and-password-auth' @@ -17,7 +18,7 @@ const NormalForm = () => { const { t } = useTranslation() const [isLoading, setIsLoading] = useState(true) - 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/(shareLayout)/webapp-signin/page.tsx b/web/app/(shareLayout)/webapp-signin/page.tsx index 4b94a7210f..a1e14ed815 100644 --- a/web/app/(shareLayout)/webapp-signin/page.tsx +++ b/web/app/(shareLayout)/webapp-signin/page.tsx @@ -1,20 +1,21 @@ 'use client' import type { FC } from 'react' +import { useSuspenseQuery } from '@tanstack/react-query' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import AppUnavailable from '@/app/components/base/app-unavailable' -import { useGlobalPublicStore } from '@/context/global-public-context' import { useWebAppStore } from '@/context/web-app-context' import { AccessMode } from '@/models/access-control' import { useRouter, useSearchParams } from '@/next/navigation' +import { systemFeaturesQueryOptions } from '@/service/system-features' import { webAppLogout } from '@/service/webapp-auth' import ExternalMemberSsoAuth from './components/external-member-sso-auth' import NormalForm from './normalForm' const WebSSOForm: FC = () => { const { t } = useTranslation() - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode) const searchParams = useSearchParams() const router = useRouter() diff --git a/web/app/account/(commonLayout)/account-page/index.tsx b/web/app/account/(commonLayout)/account-page/index.tsx index a26fa942db..09c083b60b 100644 --- a/web/app/account/(commonLayout)/account-page/index.tsx +++ b/web/app/account/(commonLayout)/account-page/index.tsx @@ -7,7 +7,7 @@ import { toast } from '@langgenius/dify-ui/toast' import { RiGraduationCapFill, } from '@remixicon/react' -import { useQueryClient } from '@tanstack/react-query' +import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query' import { useState } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' @@ -15,11 +15,11 @@ import Input from '@/app/components/base/input' import PremiumBadge from '@/app/components/base/premium-badge' import Collapse from '@/app/components/header/account-setting/collapse' import { IS_CE_EDITION, validPassword } from '@/config' -import { useGlobalPublicStore } from '@/context/global-public-context' import { useProviderContext } from '@/context/provider-context' import { updateUserProfile } from '@/service/common' +import { systemFeaturesQueryOptions } from '@/service/system-features' import { useAppList } from '@/service/use-apps' -import { commonQueryKeys, useUserProfile } from '@/service/use-common' +import { commonQueryKeys, userProfileQueryOptions } from '@/service/use-common' import DeleteAccount from '../delete-account' import AvatarWithEdit from './AvatarWithEdit' @@ -34,12 +34,13 @@ const descriptionClassName = ` export default function AccountPage() { const { t } = useTranslation() - const { systemFeatures } = useGlobalPublicStore() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { data: appList } = useAppList({ page: 1, limit: 100, name: '' }) const apps = appList?.data || [] const queryClient = useQueryClient() - const { data: userProfileResp } = useUserProfile() - const userProfile = userProfileResp?.profile + // Cache is warmed by AppContextProvider's useSuspenseQuery; this hits cache synchronously. + const { data: userProfileResp } = useSuspenseQuery(userProfileQueryOptions()) + const userProfile = userProfileResp.profile const mutateUserProfile = () => queryClient.invalidateQueries({ queryKey: commonQueryKeys.userProfile }) const { isEducationAccount } = useProviderContext() const [editNameModalVisible, setEditNameModalVisible] = useState(false) diff --git a/web/app/account/(commonLayout)/avatar.tsx b/web/app/account/(commonLayout)/avatar.tsx index 0893b130c4..ccae182c9a 100644 --- a/web/app/account/(commonLayout)/avatar.tsx +++ b/web/app/account/(commonLayout)/avatar.tsx @@ -4,6 +4,7 @@ import { Avatar } from '@langgenius/dify-ui/avatar' import { RiGraduationCapFill, } from '@remixicon/react' +import { useSuspenseQuery } from '@tanstack/react-query' import { Fragment } from 'react' import { useTranslation } from 'react-i18next' import { resetUser } from '@/app/components/base/amplitude/utils' @@ -11,13 +12,14 @@ import { LogOut01 } from '@/app/components/base/icons/src/vender/line/general' import PremiumBadge from '@/app/components/base/premium-badge' import { useProviderContext } from '@/context/provider-context' import { useRouter } from '@/next/navigation' -import { useLogout, useUserProfile } from '@/service/use-common' +import { useLogout, userProfileQueryOptions } from '@/service/use-common' export default function AppSelector() { const router = useRouter() const { t } = useTranslation() - const { data: userProfileResp } = useUserProfile() - const userProfile = userProfileResp?.profile + // Cache is warmed by AppContextProvider's useSuspenseQuery; this hits cache synchronously. + const { data: userProfileResp } = useSuspenseQuery(userProfileQueryOptions()) + const userProfile = userProfileResp.profile const { isEducationAccount } = useProviderContext() const { mutateAsync: logout } = useLogout() diff --git a/web/app/account/(commonLayout)/header.tsx b/web/app/account/(commonLayout)/header.tsx index f0912d45d5..37c1dcdd23 100644 --- a/web/app/account/(commonLayout)/header.tsx +++ b/web/app/account/(commonLayout)/header.tsx @@ -1,17 +1,18 @@ 'use client' import { Button } from '@langgenius/dify-ui/button' import { RiArrowRightUpLine, RiRobot2Line } from '@remixicon/react' +import { useSuspenseQuery } from '@tanstack/react-query' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import DifyLogo from '@/app/components/base/logo/dify-logo' -import { useGlobalPublicStore } from '@/context/global-public-context' import { useRouter } from '@/next/navigation' +import { systemFeaturesQueryOptions } from '@/service/system-features' import Avatar from './avatar' const Header = () => { const { t } = useTranslation() const router = useRouter() - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const goToStudio = useCallback(() => { router.push('/apps') diff --git a/web/app/account/oauth/authorize/layout.tsx b/web/app/account/oauth/authorize/layout.tsx index 9460e7fc54..850fe9c2b5 100644 --- a/web/app/account/oauth/authorize/layout.tsx +++ b/web/app/account/oauth/authorize/layout.tsx @@ -1,20 +1,27 @@ 'use client' import { cn } from '@langgenius/dify-ui/cn' +import { useQuery, useSuspenseQuery } from '@tanstack/react-query' import Loading from '@/app/components/base/loading' import Header from '@/app/signin/_header' import { AppContextProvider } from '@/context/app-context-provider' -import { useGlobalPublicStore } from '@/context/global-public-context' import useDocumentTitle from '@/hooks/use-document-title' -import { useIsLogin } from '@/service/use-common' +import { systemFeaturesQueryOptions } from '@/service/system-features' +import { isLegacyBase401, userProfileQueryOptions } from '@/service/use-common' export default function SignInLayout({ children }: any) { - const { systemFeatures } = useGlobalPublicStore() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) useDocumentTitle('') - const { isLoading, data: loginData } = useIsLogin() - const isLoggedIn = loginData?.logged_in + // Probe login state. 401 stays as `error` (not thrown) so this layout can render + // the signin/oauth UI for unauthenticated users; other errors bubble to error.tsx. + // (When unauthenticated, service/base.ts's auto-redirect to /signin still fires.) + const { isPending, data: userResp, error } = useQuery({ + ...userProfileQueryOptions(), + throwOnError: err => !isLegacyBase401(err), + }) + const isLoggedIn = !!userResp && !error - if (isLoading) { + if (isPending) { return (
diff --git a/web/app/account/oauth/authorize/page.tsx b/web/app/account/oauth/authorize/page.tsx index cd035ce16f..dd95dc04ba 100644 --- a/web/app/account/oauth/authorize/page.tsx +++ b/web/app/account/oauth/authorize/page.tsx @@ -10,6 +10,7 @@ import { RiMailLine, RiTranslate2, } from '@remixicon/react' +import { useQuery } from '@tanstack/react-query' import * as React from 'react' import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' @@ -17,7 +18,7 @@ import Loading from '@/app/components/base/loading' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' import { setPostLoginRedirect } from '@/app/signin/utils/post-login-redirect' import { useRouter, useSearchParams } from '@/next/navigation' -import { useIsLogin, useUserProfile } from '@/service/use-common' +import { isLegacyBase401, userProfileQueryOptions } from '@/service/use-common' import { useAuthorizeOAuthApp, useOAuthAppInfo } from '@/service/use-oauth' function buildReturnUrl(pathname: string, search: string) { @@ -61,15 +62,20 @@ export default function OAuthAuthorize() { const searchParams = useSearchParams() const client_id = decodeURIComponent(searchParams.get('client_id') || '') const redirect_uri = decodeURIComponent(searchParams.get('redirect_uri') || '') - const { data: userProfileResp } = useUserProfile() + // Probe user profile. 401 stays as `error` (legitimate "not logged in" state), + // other errors throw to the nearest error.tsx; jumpTo same-pathname guard in + // service/base.ts prevents a redirect loop here. + const { data: userProfileResp, isPending: isProfileLoading, error: profileError } = useQuery({ + ...userProfileQueryOptions(), + throwOnError: err => !isLegacyBase401(err), + }) + const isLoggedIn = !!userProfileResp && !profileError const userProfile = userProfileResp?.profile const { data: authAppInfo, isLoading: isOAuthLoading, isError } = useOAuthAppInfo(client_id, redirect_uri) const { mutateAsync: authorize, isPending: authorizing } = useAuthorizeOAuthApp() const hasNotifiedRef = useRef(false) - const { isLoading: isIsLoginLoading, data: loginData } = useIsLogin() - const isLoggedIn = loginData?.logged_in - const isLoading = isOAuthLoading || isIsLoginLoading + const isLoading = isOAuthLoading || isProfileLoading const onLoginSwitchClick = () => { try { const returnUrl = buildReturnUrl('/account/oauth/authorize', `?client_id=${encodeURIComponent(client_id)}&redirect_uri=${encodeURIComponent(redirect_uri)}`) diff --git a/web/app/activate/page.tsx b/web/app/activate/page.tsx index e7ab413005..995227ec6e 100644 --- a/web/app/activate/page.tsx +++ b/web/app/activate/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 ActivateForm from './activateForm' const Activate = () => { - const { systemFeatures } = useGlobalPublicStore() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) return (
diff --git a/web/app/components/__tests__/splash.spec.tsx b/web/app/components/__tests__/splash.spec.tsx deleted file mode 100644 index 296ef48cdb..0000000000 --- a/web/app/components/__tests__/splash.spec.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import type { MockedFunction } from 'vitest' -import { render, screen } from '@testing-library/react' -import { useUserProfile } from '@/service/use-common' -import Splash from '../splash' - -vi.mock('@/service/use-common', () => ({ - useUserProfile: vi.fn(), -})) - -const mockUseUserProfile = useUserProfile as MockedFunction - -describe('Splash', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('should render the loading indicator while the profile query is pending', () => { - mockUseUserProfile.mockReturnValue({ - isPending: true, - isError: false, - data: undefined, - } as ReturnType) - - render() - - expect(screen.getByRole('status')).toBeInTheDocument() - }) - - it('should not render the loading indicator when the profile query succeeds', () => { - mockUseUserProfile.mockReturnValue({ - isPending: false, - isError: false, - data: { - profile: { id: 'user-1' }, - meta: { - currentVersion: '1.13.3', - currentEnv: 'DEVELOPMENT', - }, - }, - } as ReturnType) - - render() - - expect(screen.queryByRole('status')).not.toBeInTheDocument() - }) - - it('should stop rendering the loading indicator when the profile query errors', () => { - mockUseUserProfile.mockReturnValue({ - isPending: false, - isError: true, - data: undefined, - error: new Error('profile request failed'), - } as ReturnType) - - render() - - expect(screen.queryByRole('status')).not.toBeInTheDocument() - }) -}) diff --git a/web/app/components/app-initializer.tsx b/web/app/components/app-initializer.tsx index e08ece6666..2c50312590 100644 --- a/web/app/components/app-initializer.tsx +++ b/web/app/components/app-initializer.tsx @@ -8,6 +8,7 @@ import { EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION, EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, } from '@/app/education-apply/constants' +import RootLoading from '@/app/loading' import { usePathname, useRouter, useSearchParams } from '@/next/navigation' import { sendGAEvent } from '@/utils/gtag' import { fetchSetupStatusWithCache } from '@/utils/setup-status' @@ -98,5 +99,5 @@ export const AppInitializer = ({ })() }, [isSetupFinished, router, pathname, searchParams, oauthNewUser]) - return init ? children : null + return init ? children : } diff --git a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx index bbac21942e..21dd8c5fc2 100644 --- a/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/access-control.spec.tsx @@ -2,8 +2,9 @@ import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control' import type { App } from '@/types/app' import { toast } from '@langgenius/dify-ui/toast' -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 as render } from '@/__tests__/utils/mock-system-features' import useAccessControlStore from '@/context/access-control-store' import { AccessMode, SubjectType } from '@/models/access-control' import AccessControlDialog from '../access-control-dialog' diff --git a/web/app/components/app/app-access-control/__tests__/index.spec.tsx b/web/app/components/app/app-access-control/__tests__/index.spec.tsx index 05b86f0290..74e7d7046c 100644 --- a/web/app/components/app/app-access-control/__tests__/index.spec.tsx +++ b/web/app/components/app/app-access-control/__tests__/index.spec.tsx @@ -1,10 +1,23 @@ +import type { ReactElement } from 'react' import type { App } from '@/types/app' import { toast } from '@langgenius/dify-ui/toast' -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 useAccessControlStore from '@/context/access-control-store' import { AccessMode } from '@/models/access-control' import AccessControl from '../index' +let mockWebappAuth = { + enabled: true, + allow_sso: true, + allow_email_password_login: false, + allow_email_code_login: false, +} + +const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { + systemFeatures: { webapp_auth: mockWebappAuth }, +}) + const mockMutateAsync = vi.fn() const mockUseUpdateAccessMode = vi.fn(() => ({ isPending: false, @@ -12,20 +25,6 @@ const mockUseUpdateAccessMode = vi.fn(() => ({ })) const mockUseAppWhiteListSubjects = vi.fn() const mockUseSearchForWhiteListCandidates = vi.fn() -let mockWebappAuth = { - enabled: true, - allow_sso: true, - allow_email_password_login: false, - allow_email_code_login: false, -} - -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector: (state: { systemFeatures: { webapp_auth: typeof mockWebappAuth } }) => unknown) => selector({ - systemFeatures: { - webapp_auth: mockWebappAuth, - }, - }), -})) vi.mock('@/service/access-control', () => ({ useAppWhiteListSubjects: (...args: unknown[]) => mockUseAppWhiteListSubjects(...args), diff --git a/web/app/components/app/app-access-control/index.tsx b/web/app/components/app/app-access-control/index.tsx index 2997d4b4cf..cff670e10f 100644 --- a/web/app/components/app/app-access-control/index.tsx +++ b/web/app/components/app/app-access-control/index.tsx @@ -5,11 +5,12 @@ import { Description as DialogDescription, DialogTitle } from '@headlessui/react import { Button } from '@langgenius/dify-ui/button' import { toast } from '@langgenius/dify-ui/toast' import { RiBuildingLine, RiGlobalLine, RiVerifiedBadgeLine } from '@remixicon/react' +import { useSuspenseQuery } from '@tanstack/react-query' import { useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { useGlobalPublicStore } from '@/context/global-public-context' import { AccessMode, SubjectType } from '@/models/access-control' import { useUpdateAccessMode } from '@/service/access-control' +import { systemFeaturesQueryOptions } from '@/service/system-features' import useAccessControlStore from '../../../../context/access-control-store' import AccessControlDialog from './access-control-dialog' import AccessControlItem from './access-control-item' @@ -24,7 +25,7 @@ type AccessControlProps = { export default function AccessControl(props: AccessControlProps) { const { app, onClose, onConfirm } = props const { t } = useTranslation() - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const setAppId = useAccessControlStore(s => s.setAppId) const specificGroups = useAccessControlStore(s => s.specificGroups) const specificMembers = useAccessControlStore(s => s.specificMembers) diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx index 06d91e9400..aa9cda8e34 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -1,11 +1,16 @@ /* eslint-disable ts/no-explicit-any */ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import * as React from 'react' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' import { basePath } from '@/utils/var' import AppPublisher from '../index' +const render = (ui: React.ReactElement) => renderWithSystemFeatures(ui, { + systemFeatures: { webapp_auth: { enabled: true } }, +}) + const mockOnPublish = vi.fn() const mockOnToggle = vi.fn() const mockSetAppDetail = vi.fn() @@ -49,16 +54,6 @@ vi.mock('@/app/components/app/store', () => ({ }), })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector: (state: { systemFeatures: { webapp_auth: { enabled: boolean } } }) => unknown) => selector({ - systemFeatures: { - webapp_auth: { - enabled: true, - }, - }, - }), -})) - vi.mock('@/hooks/use-format-time-from-now', () => ({ useFormatTimeFromNow: () => ({ formatTimeFromNow: () => 'moments ago', diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 562f2d7759..b85e888557 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -5,6 +5,7 @@ import type { PublishWorkflowParams } from '@/types/workflow' import { Button } from '@langgenius/dify-ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { toast } from '@langgenius/dify-ui/toast' +import { useSuspenseQuery } from '@tanstack/react-query' import { useKeyPress } from 'ahooks' import { memo, @@ -21,13 +22,13 @@ import { trackEvent } from '@/app/components/base/amplitude' import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager' import { WorkflowContext } from '@/app/components/workflow/context' -import { useGlobalPublicStore } from '@/context/global-public-context' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import { AccessMode } from '@/models/access-control' import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control' import { fetchAppDetailDirect } from '@/service/apps' import { fetchInstalledAppList } from '@/service/explore' +import { systemFeaturesQueryOptions } from '@/service/system-features' import { useInvalidateAppWorkflow } from '@/service/use-workflow' import { fetchPublishedWorkflow } from '@/service/workflow' import { AppModeEnum } from '@/types/app' @@ -103,7 +104,7 @@ const AppPublisher = ({ const workflowStore = useContext(WorkflowContext) const appDetail = useAppStore(state => state.appDetail) const setAppDetail = useAppStore(s => s.setAppDetail) - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { formatTimeFromNow } = useFormatTimeFromNow() const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {} diff --git a/web/app/components/app/create-app-dialog/app-card/__tests__/index.spec.tsx b/web/app/components/app/create-app-dialog/app-card/__tests__/index.spec.tsx index 2d76c12b68..16971f77d5 100644 --- a/web/app/components/app/create-app-dialog/app-card/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/app-card/__tests__/index.spec.tsx @@ -1,8 +1,9 @@ /* eslint-disable ts/no-explicit-any */ import type { App } from '@/models/explore' import type { AppIconType } from '@/types/app' -import { render, screen, within } from '@testing-library/react' +import { screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features' import { trackEvent } from '@/app/components/base/amplitude' import AppListContext from '@/context/app-list-context' import { AppModeEnum } from '@/types/app' diff --git a/web/app/components/app/create-app-dialog/app-card/index.tsx b/web/app/components/app/create-app-dialog/app-card/index.tsx index 65bd74344a..e710e21436 100644 --- a/web/app/components/app/create-app-dialog/app-card/index.tsx +++ b/web/app/components/app/create-app-dialog/app-card/index.tsx @@ -4,13 +4,14 @@ 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 { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useContextSelector } from 'use-context-selector' import { trackEvent } from '@/app/components/base/amplitude' import AppIcon from '@/app/components/base/app-icon' import AppListContext from '@/context/app-list-context' -import { useGlobalPublicStore } from '@/context/global-public-context' +import { systemFeaturesQueryOptions } from '@/service/system-features' import { AppTypeIcon, AppTypeLabel } from '../../type-selector' type AppCardProps = { @@ -26,7 +27,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 setShowTryAppPanel = useContextSelector(AppListContext, ctx => ctx.setShowTryAppPanel) const handleShowTryAppPanel = useCallback(() => { diff --git a/web/app/components/app/overview/__tests__/app-card.spec.tsx b/web/app/components/app/overview/__tests__/app-card.spec.tsx index 0f67093e47..43c0887566 100644 --- a/web/app/components/app/overview/__tests__/app-card.spec.tsx +++ b/web/app/components/app/overview/__tests__/app-card.spec.tsx @@ -1,11 +1,16 @@ -import type { ReactNode } from 'react' +import type { ReactElement, ReactNode } from 'react' import type { AppDetailResponse } from '@/models/app' -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 { AccessMode } from '@/models/access-control' import { AppModeEnum } from '@/types/app' import { basePath } from '@/utils/var' import AppCard from '../app-card' +const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { + systemFeatures: { webapp_auth: { enabled: true } }, +}) + const mockFetchAppDetailDirect = vi.fn() const mockPush = vi.fn() const mockSetAppDetail = vi.fn() @@ -36,16 +41,6 @@ vi.mock('@/context/i18n', () => ({ useDocLink: () => (path: string) => `https://docs.example.com${path}`, })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector: (state: { systemFeatures: { webapp_auth: { enabled: boolean } } }) => unknown) => selector({ - systemFeatures: { - webapp_auth: { - enabled: true, - }, - }, - }), -})) - vi.mock('@/app/components/app/store', () => ({ useStore: (selector: (state: { appDetail: AppDetailResponse, setAppDetail: typeof mockSetAppDetail }) => unknown) => selector({ appDetail: mockAppDetail as AppDetailResponse, diff --git a/web/app/components/app/overview/app-card.tsx b/web/app/components/app/overview/app-card.tsx index 5cfe3f65ac..f0502ae918 100644 --- a/web/app/components/app/overview/app-card.tsx +++ b/web/app/components/app/overview/app-card.tsx @@ -3,6 +3,7 @@ import type { ConfigParams } from './settings' import type { AppDetailResponse } from '@/models/app' import type { AppSSO } from '@/types/app' import { Switch } from '@langgenius/dify-ui/switch' +import { useSuspenseQuery } from '@tanstack/react-query' import * as React from 'react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -12,12 +13,12 @@ import Tooltip from '@/app/components/base/tooltip' import SecretKeyButton from '@/app/components/develop/secret-key/secret-key-button' import Indicator from '@/app/components/header/indicator' import { useAppContext } from '@/context/app-context' -import { useGlobalPublicStore } from '@/context/global-public-context' import { useDocLink } from '@/context/i18n' import { AccessMode } from '@/models/access-control' import { usePathname, useRouter } from '@/next/navigation' import { useAppWhiteListSubjects } from '@/service/access-control' import { fetchAppDetailDirect } from '@/service/apps' +import { systemFeaturesQueryOptions } from '@/service/system-features' import { useAppWorkflow } from '@/service/use-workflow' import { AppModeEnum } from '@/types/app' import { asyncRunSafe } from '@/utils' @@ -73,7 +74,7 @@ function AppCard({ const [showConfirmDelete, setShowConfirmDelete] = useState(false) const [showAccessControl, setShowAccessControl] = useState(false) const { t } = useTranslation() - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { data: appAccessSubjects } = useAppWhiteListSubjects( appDetail?.id, systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS, diff --git a/web/app/components/apps/__tests__/app-card.spec.tsx b/web/app/components/apps/__tests__/app-card.spec.tsx index 0c80ee000e..6a71dbac52 100644 --- a/web/app/components/apps/__tests__/app-card.spec.tsx +++ b/web/app/components/apps/__tests__/app-card.spec.tsx @@ -1,7 +1,8 @@ import type { Mock } from 'vitest' import type { App } from '@/types/app' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import * as React from 'react' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { AccessMode } from '@/models/access-control' import * as appsService from '@/service/apps' import * as exploreService from '@/service/explore' @@ -9,6 +10,15 @@ import * as workflowService from '@/service/workflow' import { AppModeEnum } from '@/types/app' import AppCard from '../app-card' +let mockWebappAuthEnabled = false + +const render = (ui: React.ReactElement) => renderWithSystemFeatures(ui, { + systemFeatures: { + webapp_auth: { enabled: mockWebappAuthEnabled }, + branding: { enabled: false }, + }, +}) + // Mock next/navigation const mockPush = vi.fn() vi.mock('@/next/navigation', () => ({ @@ -65,16 +75,7 @@ vi.mock('@/context/provider-context', () => ({ }), })) -// Mock global public store - allow dynamic configuration -let mockWebappAuthEnabled = false -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: (selector: (s: Record) => unknown) => selector({ - systemFeatures: { - webapp_auth: { enabled: mockWebappAuthEnabled }, - branding: { enabled: false }, - }, - }), -})) +// systemFeatures is seeded into the QueryClient via the local render helper. vi.mock('@/service/apps', () => ({ deleteApp: vi.fn(() => Promise.resolve()), diff --git a/web/app/components/apps/__tests__/list.spec.tsx b/web/app/components/apps/__tests__/list.spec.tsx index eddcb31d60..c3ce96255a 100644 --- a/web/app/components/apps/__tests__/list.spec.tsx +++ b/web/app/components/apps/__tests__/list.spec.tsx @@ -1,5 +1,6 @@ import { act, fireEvent, screen } from '@testing-library/react' import * as React from 'react' +import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features' import { useStore as useTagStore } from '@/app/components/base/tag-management/store' import { renderWithNuqs } from '@/test/nuqs-testing' import { AppModeEnum } from '@/types/app' @@ -22,14 +23,6 @@ vi.mock('@/context/app-context', () => ({ }), })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: () => ({ - systemFeatures: { - branding: { enabled: false }, - }, - }), -})) - const mockSetQuery = vi.fn() const mockQueryState = { tagIDs: [] as string[], @@ -192,9 +185,13 @@ beforeAll(() => { } as unknown as typeof IntersectionObserver }) -// Render helper wrapping with shared nuqs testing helper. +// Render helper wrapping with shared nuqs testing helper plus a seeded +// systemFeatures cache so List can resolve its useSuspenseQuery. const renderList = (searchParams = '') => { - return renderWithNuqs(, { searchParams }) + const { wrapper: SystemFeaturesWrapper } = createSystemFeaturesWrapper({ + systemFeatures: { branding: { enabled: false } }, + }) + return renderWithNuqs(, { searchParams }) } describe('List', () => { @@ -390,7 +387,7 @@ describe('List', () => { describe('Edge Cases', () => { it('should handle multiple renders without issues', () => { - const { unmount } = renderWithNuqs() + const { unmount } = renderList() expect(screen.getByText('app.types.all'))!.toBeInTheDocument() unmount() diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index d0bd9967c4..80aab3ce4d 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -24,6 +24,7 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { toast } from '@langgenius/dify-ui/toast' +import { useSuspenseQuery } from '@tanstack/react-query' import * as React from 'react' import { useCallback, useEffect, useId, useMemo, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' @@ -35,7 +36,6 @@ import Tooltip from '@/app/components/base/tooltip' import { UserAvatarList } from '@/app/components/base/user-avatar-list' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useAppContext } from '@/context/app-context' -import { useGlobalPublicStore } from '@/context/global-public-context' import { useProviderContext } from '@/context/provider-context' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import { AccessMode } from '@/models/access-control' @@ -44,6 +44,7 @@ import { useRouter } from '@/next/navigation' import { useGetUserCanAccessApp } from '@/service/access-control' import { copyApp, exportAppConfig, updateAppInfo } from '@/service/apps' import { fetchInstalledAppList } from '@/service/explore' +import { systemFeaturesQueryOptions } from '@/service/system-features' import { useDeleteAppMutation } from '@/service/use-apps' import { fetchWorkflowDraft } from '@/service/workflow' import { AppModeEnum } from '@/types/app' @@ -182,7 +183,7 @@ const AppCardOperationsMenu: React.FC = ({ type AppCardOperationsMenuContentProps = Omit const AppCardOperationsMenuContent: React.FC = (props) => { - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({ appId: props.app.id, enabled: systemFeatures.webapp_auth.enabled, @@ -205,7 +206,7 @@ const AppCardOperationsMenuContent: React.FC const AppCard = ({ app, onlineUsers = [], onRefresh }: AppCardProps) => { const { t } = useTranslation() const deleteAppNameInputId = useId() - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { isCurrentWorkspaceEditor } = useAppContext() const { onPlanInfoChanged } = useProviderContext() const { push } = useRouter() diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index 3b8784ae45..d1bdf533fe 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -3,6 +3,7 @@ import type { FC } from 'react' import type { WorkflowOnlineUser } from '@/models/app' import { cn } from '@langgenius/dify-ui/cn' +import { useSuspenseQuery } from '@tanstack/react-query' import { useDebounceFn } from 'ahooks' import { parseAsStringLiteral, useQueryState } from 'nuqs' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -14,10 +15,10 @@ import TagFilter from '@/app/components/base/tag-management/filter' import { useStore as useTagStore } from '@/app/components/base/tag-management/store' import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { useAppContext } from '@/context/app-context' -import { useGlobalPublicStore } from '@/context/global-public-context' import { CheckModal } from '@/hooks/use-pay' import dynamic from '@/next/dynamic' import { fetchWorkflowOnlineUsers } from '@/service/apps' +import { systemFeaturesQueryOptions } from '@/service/system-features' import { useInfiniteAppList } from '@/service/use-apps' import { AppModeEnum, AppModes } from '@/types/app' import AppCard from './app-card' @@ -54,7 +55,7 @@ const List: FC = ({ controlRefreshList = 0, }) => { const { t } = useTranslation() - const { systemFeatures } = useGlobalPublicStore() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext() const showTagManagementModal = useTagStore(s => s.showTagManagementModal) const [activeTab, setActiveTab] = useQueryState( diff --git a/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx index af58a29fcc..4cbe4ce8d1 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/header-in-mobile.spec.tsx @@ -2,8 +2,9 @@ import type { i18n } from 'i18next' import type { ChatConfig } from '../../types' import type { ChatWithHistoryContextValue } from '../context' import type { AppData, AppMeta } from '@/models/share' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import * as ReactI18next from 'react-i18next' +import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { useChatWithHistoryContext } from '../context' import HeaderInMobile from '../header-in-mobile' diff --git a/web/app/components/base/chat/chat-with-history/__tests__/index.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/index.spec.tsx index e306569140..c9398ee927 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/index.spec.tsx @@ -2,7 +2,8 @@ import type { RefObject } from 'react' import type { ChatConfig } from '../../types' import type { InstalledApp } from '@/models/explore' import type { AppConversationData, AppData, AppMeta, ConversationItem } from '@/models/share' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useDocumentTitle from '@/hooks/use-document-title' import { useChatWithHistory } from '../hooks' diff --git a/web/app/components/base/chat/chat-with-history/sidebar/__tests__/index.spec.tsx b/web/app/components/base/chat/chat-with-history/sidebar/__tests__/index.spec.tsx index 948700c2ce..170f6d7fb5 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/__tests__/index.spec.tsx @@ -1,23 +1,18 @@ +import type { ReactElement } from 'react' import type { ChatWithHistoryContextValue } from '../../context' -import { render, screen, waitFor, within } from '@testing-library/react' +import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import * as React from 'react' import * as ReactI18next from 'react-i18next' -import { useGlobalPublicStore } from '@/context/global-public-context' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { useChatWithHistoryContext } from '../../context' import Sidebar from '../index' import RenameModal from '../rename-modal' -// Type for mocking the global public store selector -type GlobalPublicStoreMock = { - systemFeatures: { - branding: { - enabled: boolean - workspace_logo: string | null - } - } - setSystemFeatures?: (features: unknown) => void -} +let mockBranding: { enabled: boolean, workspace_logo: string } = { enabled: false, workspace_logo: '' } +const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { + systemFeatures: { branding: { ...mockBranding } }, +}) function mockUseTranslationWithEmptyKeys(emptyKeys: string[]) { const originalUseTranslation = ReactI18next.useTranslation @@ -38,19 +33,6 @@ function mockUseTranslationWithEmptyKeys(emptyKeys: string[]) { }) } -// Helper to create properly-typed mock store state -function createMockStoreState(overrides: Partial): GlobalPublicStoreMock { - return { - systemFeatures: { - branding: { - enabled: false, - workspace_logo: null, - }, - }, - ...overrides, - } -} - // Mock List to allow us to trigger operations vi.mock('../list', () => ({ default: ({ list, onOperate, title, isPin }: { list: Array<{ id: string, name: string }>, onOperate: (type: string, item: { id: string, name: string }) => void, title?: string, isPin?: boolean }) => ( @@ -74,18 +56,6 @@ vi.mock('../../context', () => ({ useChatWithHistoryContext: vi.fn(), })) -// Mock global public store -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: vi.fn(selector => selector({ - systemFeatures: { - branding: { - enabled: false, - workspace_logo: null, - }, - }, - })), -})) - // Mock next/navigation vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }), @@ -139,8 +109,8 @@ describe('Sidebar Index', () => { beforeEach(() => { vi.clearAllMocks() + mockBranding = { enabled: false, workspace_logo: '' } vi.mocked(useChatWithHistoryContext).mockReturnValue(mockContextValue) - vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector(createMockStoreState({}) as never)) }) describe('Basic Rendering', () => { @@ -658,17 +628,7 @@ describe('Sidebar Index', () => { }) it('should use system branding logo when enabled', () => { - const mockStoreState = createMockStoreState({ - systemFeatures: { - branding: { - enabled: true, - workspace_logo: 'http://example.com/workspace-logo.png', - }, - }, - }) - - vi.mocked(useGlobalPublicStore).mockClear() - vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector(mockStoreState as never)) + mockBranding = { enabled: true, workspace_logo: 'http://example.com/workspace-logo.png' } vi.mocked(useChatWithHistoryContext).mockReturnValue({ ...mockContextValue, diff --git a/web/app/components/base/chat/chat-with-history/sidebar/index.tsx b/web/app/components/base/chat/chat-with-history/sidebar/index.tsx index 999f035301..74efb91e83 100644 --- a/web/app/components/base/chat/chat-with-history/sidebar/index.tsx +++ b/web/app/components/base/chat/chat-with-history/sidebar/index.tsx @@ -15,6 +15,7 @@ import { RiExpandRightLine, RiLayoutLeft2Line, } from '@remixicon/react' +import { useSuspenseQuery } from '@tanstack/react-query' import { useCallback, useState, @@ -26,7 +27,7 @@ import List from '@/app/components/base/chat/chat-with-history/sidebar/list' import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal' import DifyLogo from '@/app/components/base/logo/dify-logo' import MenuDropdown from '@/app/components/share/text-generation/menu-dropdown' -import { useGlobalPublicStore } from '@/context/global-public-context' +import { systemFeaturesQueryOptions } from '@/service/system-features' import { useChatWithHistoryContext } from '../context' type Props = { @@ -55,7 +56,7 @@ const Sidebar = ({ isPanel, panelVisible }: Props) => { isResponding, } = useChatWithHistoryContext() const isSidebarCollapsed = sidebarCollapseState - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const [showConfirm, setShowConfirm] = useState(null) const [showRename, setShowRename] = useState(null) diff --git a/web/app/components/base/chat/embedded-chatbot/__tests__/index.spec.tsx b/web/app/components/base/chat/embedded-chatbot/__tests__/index.spec.tsx index a87c206412..0cd22c97be 100644 --- a/web/app/components/base/chat/embedded-chatbot/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/embedded-chatbot/__tests__/index.spec.tsx @@ -1,14 +1,20 @@ -import type { RefObject } from 'react' +import type { ReactElement, RefObject } from 'react' import type { ChatConfig } from '../../types' import type { AppData, AppMeta, ConversationItem } from '@/models/share' -import { render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' import { vi } from 'vitest' -import { useGlobalPublicStore } from '@/context/global-public-context' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' -import { defaultSystemFeatures } from '@/types/feature' import { useEmbeddedChatbot } from '../hooks' import EmbeddedChatbot from '../index' +let mockBrandingWorkspaceLogo = '' +const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { + systemFeatures: { + branding: { enabled: true, workspace_logo: mockBrandingWorkspaceLogo }, + }, +}) + vi.mock('../hooks', () => ({ useEmbeddedChatbot: vi.fn(), })) @@ -26,10 +32,6 @@ vi.mock('@/hooks/use-document-title', () => ({ default: vi.fn(), })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: vi.fn(), -})) - vi.mock('../chat-wrapper', () => ({ __esModule: true, default: () =>
chat area
, @@ -125,19 +127,9 @@ const createHookReturn = (overrides: Partial = {}): E describe('EmbeddedChatbot index', () => { beforeEach(() => { vi.clearAllMocks() + mockBrandingWorkspaceLogo = '' vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile) vi.mocked(useEmbeddedChatbot).mockReturnValue(createHookReturn()) - vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({ - systemFeatures: { - ...defaultSystemFeatures, - branding: { - ...defaultSystemFeatures.branding, - enabled: true, - workspace_logo: '', - }, - }, - setSystemFeatures: vi.fn(), - })) }) describe('Loading and chat content', () => { @@ -159,17 +151,7 @@ describe('EmbeddedChatbot index', () => { describe('Powered by branding', () => { it('should show workspace logo on mobile when branding is enabled', () => { - vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({ - systemFeatures: { - ...defaultSystemFeatures, - branding: { - ...defaultSystemFeatures.branding, - enabled: true, - workspace_logo: 'https://example.com/workspace-logo.png', - }, - }, - setSystemFeatures: vi.fn(), - })) + mockBrandingWorkspaceLogo = 'https://example.com/workspace-logo.png' render() diff --git a/web/app/components/base/chat/embedded-chatbot/header/__tests__/index.spec.tsx b/web/app/components/base/chat/embedded-chatbot/header/__tests__/index.spec.tsx index 50ba15ae61..3142bcd315 100644 --- a/web/app/components/base/chat/embedded-chatbot/header/__tests__/index.spec.tsx +++ b/web/app/components/base/chat/embedded-chatbot/header/__tests__/index.spec.tsx @@ -1,30 +1,25 @@ +import type { ReactElement } from 'react' import type { EmbeddedChatbotContextValue } from '../../context' import type { AppData } from '@/models/share' -import type { SystemFeatures } from '@/types/feature' -import { act, render, screen, waitFor } from '@testing-library/react' +import { act, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { useGlobalPublicStore } from '@/context/global-public-context' -import { InstallationScope, LicenseStatus } from '@/types/feature' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { useEmbeddedChatbotContext } from '../../context' import Header from '../index' +let mockBranding = { enabled: true, workspace_logo: '' } +const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { + systemFeatures: { branding: { ...mockBranding } }, +}) + vi.mock('../../context', () => ({ useEmbeddedChatbotContext: vi.fn(), })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: vi.fn(), -})) - vi.mock('@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown', () => ({ default: () =>
, })) -type GlobalPublicStoreMock = { - systemFeatures: SystemFeatures - setSystemFeatures: (systemFeatures: SystemFeatures) => void -} - describe('EmbeddedChatbot Header', () => { const defaultAppData: AppData = { app_id: 'test-app-id', @@ -47,48 +42,6 @@ describe('EmbeddedChatbot Header', () => { allInputsHidden: false, } - const defaultSystemFeatures: SystemFeatures = { - app_dsl_version: '', - trial_models: [], - plugin_installation_permission: { - plugin_installation_scope: InstallationScope.ALL, - restrict_to_marketplace_only: false, - }, - sso_enforced_for_signin: false, - sso_enforced_for_signin_protocol: '', - sso_enforced_for_web: false, - sso_enforced_for_web_protocol: '', - enable_marketplace: false, - enable_change_email: false, - enable_email_code_login: false, - enable_email_password_login: false, - enable_social_oauth_login: false, - is_allow_create_workspace: false, - is_allow_register: false, - is_email_setup: false, - license: { - status: LicenseStatus.NONE, - expired_at: '', - }, - branding: { - enabled: true, - workspace_logo: '', - login_page_logo: '', - favicon: '', - application_title: '', - }, - webapp_auth: { - enabled: false, - allow_sso: false, - sso_config: { protocol: '' }, - allow_email_code_login: false, - allow_email_password_login: false, - }, - enable_collaboration_mode: false, - enable_trial_app: false, - enable_explore_banner: false, - } - const setupIframe = () => { const mockPostMessage = vi.fn() const mockTop = { postMessage: mockPostMessage } @@ -100,11 +53,8 @@ describe('EmbeddedChatbot Header', () => { beforeEach(() => { vi.clearAllMocks() + mockBranding = { enabled: true, workspace_logo: '' } vi.mocked(useEmbeddedChatbotContext).mockReturnValue(defaultContext as EmbeddedChatbotContextValue) - vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({ - systemFeatures: defaultSystemFeatures, - setSystemFeatures: vi.fn(), - })) Object.defineProperty(window, 'self', { value: window, configurable: true }) Object.defineProperty(window, 'top', { value: window, configurable: true }) @@ -149,16 +99,7 @@ describe('EmbeddedChatbot Header', () => { }) it('should render workspace logo when branding is enabled and logo exists', () => { - vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({ - systemFeatures: { - ...defaultSystemFeatures, - branding: { - ...defaultSystemFeatures.branding, - workspace_logo: 'https://example.com/workspace.png', - }, - }, - setSystemFeatures: vi.fn(), - })) + mockBranding = { enabled: true, workspace_logo: 'https://example.com/workspace.png' } render(
) @@ -167,32 +108,13 @@ describe('EmbeddedChatbot Header', () => { }) it('should render Dify logo by default when branding enabled is true but no logo provided', () => { - vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({ - systemFeatures: { - ...defaultSystemFeatures, - branding: { - ...defaultSystemFeatures.branding, - enabled: true, - workspace_logo: '', - }, - }, - setSystemFeatures: vi.fn(), - })) + mockBranding = { enabled: true, workspace_logo: '' } render(
) expect(screen.getByAltText('Dify logo')).toBeInTheDocument() }) it('should render Dify logo when branding is disabled', () => { - vi.mocked(useGlobalPublicStore).mockImplementation((selector: (s: GlobalPublicStoreMock) => unknown) => selector({ - systemFeatures: { - ...defaultSystemFeatures, - branding: { - ...defaultSystemFeatures.branding, - enabled: false, - }, - }, - setSystemFeatures: vi.fn(), - })) + mockBranding = { enabled: false, workspace_logo: '' } render(
) expect(screen.getByAltText('Dify logo')).toBeInTheDocument() }) diff --git a/web/app/components/base/chat/embedded-chatbot/header/index.tsx b/web/app/components/base/chat/embedded-chatbot/header/index.tsx index aeec29a477..598e3068de 100644 --- a/web/app/components/base/chat/embedded-chatbot/header/index.tsx +++ b/web/app/components/base/chat/embedded-chatbot/header/index.tsx @@ -1,6 +1,7 @@ import type { FC } from 'react' import type { Theme } from '../theme/theme-context' import { cn } from '@langgenius/dify-ui/cn' +import { useSuspenseQuery } from '@tanstack/react-query' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -9,7 +10,7 @@ import ViewFormDropdown from '@/app/components/base/chat/embedded-chatbot/inputs import Divider from '@/app/components/base/divider' import DifyLogo from '@/app/components/base/logo/dify-logo' import Tooltip from '@/app/components/base/tooltip' -import { useGlobalPublicStore } from '@/context/global-public-context' +import { systemFeaturesQueryOptions } from '@/service/system-features' import { isClient } from '@/utils/client' import { useEmbeddedChatbotContext, @@ -44,7 +45,7 @@ const Header: FC = ({ const [parentOrigin, setParentOrigin] = useState('') const [showToggleExpandButton, setShowToggleExpandButton] = useState(false) const [expanded, setExpanded] = useState(false) - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const handleMessageReceived = useCallback((event: MessageEvent) => { let currentParentOrigin = parentOrigin diff --git a/web/app/components/base/chat/embedded-chatbot/index.tsx b/web/app/components/base/chat/embedded-chatbot/index.tsx index 7c0f2feb7a..886549ca96 100644 --- a/web/app/components/base/chat/embedded-chatbot/index.tsx +++ b/web/app/components/base/chat/embedded-chatbot/index.tsx @@ -1,6 +1,7 @@ 'use client' import type { AppData } from '@/models/share' import { cn } from '@langgenius/dify-ui/cn' +import { useSuspenseQuery } from '@tanstack/react-query' import { useEffect, } from 'react' @@ -10,10 +11,10 @@ import Header from '@/app/components/base/chat/embedded-chatbot/header' import Loading from '@/app/components/base/loading' import DifyLogo from '@/app/components/base/logo/dify-logo' import LogoHeader from '@/app/components/base/logo/logo-embedded-chat-header' -import { useGlobalPublicStore } from '@/context/global-public-context' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useDocumentTitle from '@/hooks/use-document-title' import { AppSourceType } from '@/service/share' +import { systemFeaturesQueryOptions } from '@/service/system-features' import { EmbeddedChatbotContext, useEmbeddedChatbotContext, @@ -34,7 +35,7 @@ const Chatbot = () => { themeBuilder, } = useEmbeddedChatbotContext() const { t } = useTranslation() - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const customConfig = appData?.custom_config const site = appData?.site diff --git a/web/app/components/custom/custom-page/__tests__/index.spec.tsx b/web/app/components/custom/custom-page/__tests__/index.spec.tsx index d6cc15ed2b..1f3655a9f8 100644 --- a/web/app/components/custom/custom-page/__tests__/index.spec.tsx +++ b/web/app/components/custom/custom-page/__tests__/index.spec.tsx @@ -1,9 +1,10 @@ +import type { ReactElement } from 'react' import type { AppContextValue } from '@/context/app-context' -import type { SystemFeatures } from '@/types/feature' -import { render, screen } from '@testing-library/react' +import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createMockProviderContextValue } from '@/__mocks__/provider-context' +import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { contactSalesUrl, defaultPlan } from '@/app/components/billing/config' import { Plan } from '@/app/components/billing/type' import { @@ -12,12 +13,19 @@ import { useAppContext, userProfilePlaceholder, } from '@/context/app-context' -import { useGlobalPublicStore } from '@/context/global-public-context' import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' -import { defaultSystemFeatures } from '@/types/feature' import CustomPage from '../index' +const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { + systemFeatures: { + branding: { + enabled: true, + workspace_logo: 'https://example.com/workspace-logo.png', + }, + }, +}) + const { mockToast } = vi.hoisted(() => { const mockToast = Object.assign(vi.fn(), { success: vi.fn(), @@ -44,9 +52,6 @@ vi.mock('@/context/app-context', async (importOriginal) => { useAppContext: vi.fn(), } }) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: vi.fn(), -})) vi.mock('@langgenius/dify-ui/toast', () => ({ toast: mockToast, })) @@ -54,7 +59,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ const mockUseProviderContext = vi.mocked(useProviderContext) const mockUseModalContext = vi.mocked(useModalContext) const mockUseAppContext = vi.mocked(useAppContext) -const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore) const createProviderContext = ({ enableBilling = false, @@ -93,15 +97,6 @@ const createAppContextValue = (): AppContextValue => ({ isValidatingCurrentWorkspace: false, }) -const createSystemFeatures = (): SystemFeatures => ({ - ...defaultSystemFeatures, - branding: { - ...defaultSystemFeatures.branding, - enabled: true, - workspace_logo: 'https://example.com/workspace-logo.png', - }, -}) - describe('CustomPage', () => { const setShowPricingModal = vi.fn() @@ -113,10 +108,6 @@ describe('CustomPage', () => { setShowPricingModal, } as unknown as ReturnType) mockUseAppContext.mockReturnValue(createAppContextValue()) - mockUseGlobalPublicStore.mockImplementation(selector => selector({ - systemFeatures: createSystemFeatures(), - setSystemFeatures: vi.fn(), - })) }) // Integration coverage for the page and its child custom brand section. diff --git a/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx b/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx index 3ca7c34b84..99cbc03b32 100644 --- a/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx +++ b/web/app/components/custom/custom-web-app-brand/hooks/__tests__/use-web-app-brand.spec.tsx @@ -1,9 +1,10 @@ import type { ChangeEvent } from 'react' import type { AppContextValue } from '@/context/app-context' import type { SystemFeatures } from '@/types/feature' -import { act, renderHook } from '@testing-library/react' +import { act } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createMockProviderContextValue } from '@/__mocks__/provider-context' +import { renderHookWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils' import { defaultPlan } from '@/app/components/billing/config' import { Plan } from '@/app/components/billing/type' @@ -13,12 +14,22 @@ import { useAppContext, userProfilePlaceholder, } from '@/context/app-context' -import { useGlobalPublicStore } from '@/context/global-public-context' import { useProviderContext } from '@/context/provider-context' import { updateCurrentWorkspace } from '@/service/common' -import { defaultSystemFeatures } from '@/types/feature' import useWebAppBrand from '../use-web-app-brand' +let currentBrandingOverrides: Partial = {} +const renderHook = (callback: (props: Props) => Result) => + renderHookWithSystemFeatures(callback, { + systemFeatures: { + branding: { + enabled: true, + workspace_logo: 'https://example.com/workspace-logo.png', + ...currentBrandingOverrides, + }, + }, + }) + const { mockNotify, mockToast } = vi.hoisted(() => { const mockNotify = vi.fn() const mockToast = Object.assign(mockNotify, { @@ -49,9 +60,6 @@ vi.mock('@/context/app-context', async (importOriginal) => { vi.mock('@/context/provider-context', () => ({ useProviderContext: vi.fn(), })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: vi.fn(), -})) vi.mock('@/app/components/base/image-uploader/utils', () => ({ imageUpload: vi.fn(), getImageUploadErrorMessage: vi.fn(), @@ -60,7 +68,6 @@ vi.mock('@/app/components/base/image-uploader/utils', () => ({ const mockUpdateCurrentWorkspace = vi.mocked(updateCurrentWorkspace) const mockUseAppContext = vi.mocked(useAppContext) const mockUseProviderContext = vi.mocked(useProviderContext) -const mockUseGlobalPublicStore = vi.mocked(useGlobalPublicStore) const mockImageUpload = vi.mocked(imageUpload) const mockGetImageUploadErrorMessage = vi.mocked(getImageUploadErrorMessage) @@ -80,16 +87,6 @@ const createProviderContext = ({ }) } -const createSystemFeatures = (brandingOverrides: Partial = {}): SystemFeatures => ({ - ...defaultSystemFeatures, - branding: { - ...defaultSystemFeatures.branding, - enabled: true, - workspace_logo: 'https://example.com/workspace-logo.png', - ...brandingOverrides, - }, -}) - const createAppContextValue = (overrides: Partial = {}): AppContextValue => { const { currentWorkspace: currentWorkspaceOverride, ...restOverrides } = overrides const workspaceOverrides: Partial = currentWorkspaceOverride ?? {} @@ -122,21 +119,16 @@ const createAppContextValue = (overrides: Partial = {}): AppCon describe('useWebAppBrand', () => { let appContextValue: AppContextValue - let systemFeatures: SystemFeatures beforeEach(() => { vi.clearAllMocks() appContextValue = createAppContextValue() - systemFeatures = createSystemFeatures() + currentBrandingOverrides = {} mockUpdateCurrentWorkspace.mockResolvedValue(appContextValue.currentWorkspace) mockUseAppContext.mockImplementation(() => appContextValue) mockUseProviderContext.mockReturnValue(createProviderContext()) - mockUseGlobalPublicStore.mockImplementation(selector => selector({ - systemFeatures, - setSystemFeatures: vi.fn(), - })) mockGetImageUploadErrorMessage.mockReturnValue('upload error') }) @@ -174,10 +166,7 @@ describe('useWebAppBrand', () => { }) it('should fall back to an empty workspace logo when branding is disabled', () => { - systemFeatures = createSystemFeatures({ - enabled: false, - workspace_logo: '', - }) + currentBrandingOverrides = { enabled: false, workspace_logo: '' } const { result } = renderHook(() => useWebAppBrand()) diff --git a/web/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand.ts b/web/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand.ts index 145e7ee806..e24edab421 100644 --- a/web/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand.ts +++ b/web/app/components/custom/custom-web-app-brand/hooks/use-web-app-brand.ts @@ -1,13 +1,14 @@ import type { ChangeEvent } from 'react' import { toast } from '@langgenius/dify-ui/toast' +import { useSuspenseQuery } from '@tanstack/react-query' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { getImageUploadErrorMessage, imageUpload } from '@/app/components/base/image-uploader/utils' 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 { updateCurrentWorkspace } from '@/service/common' +import { systemFeaturesQueryOptions } from '@/service/system-features' const MAX_LOGO_FILE_SIZE = 5 * 1024 * 1024 const CUSTOM_CONFIG_URL = '/workspaces/custom-config' @@ -19,7 +20,7 @@ const useWebAppBrand = () => { const [fileId, setFileId] = useState('') const [imgKey, setImgKey] = useState(() => Date.now()) const [uploadProgress, setUploadProgress] = useState(0) - const systemFeatures = useGlobalPublicStore(s => s.systemFeatures) + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const isSandbox = enableBilling && plan.type === Plan.sandbox const uploading = uploadProgress > 0 && uploadProgress < 100 const webappLogo = currentWorkspace.custom_config?.replace_webapp_logo || '' diff --git a/web/app/components/datasets/create-from-pipeline/list/__tests__/built-in-pipeline-list.spec.tsx b/web/app/components/datasets/create-from-pipeline/list/__tests__/built-in-pipeline-list.spec.tsx index f40c87b7ac..a34952cde3 100644 --- a/web/app/components/datasets/create-from-pipeline/list/__tests__/built-in-pipeline-list.spec.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/__tests__/built-in-pipeline-list.spec.tsx @@ -1,8 +1,14 @@ -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 { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import BuiltInPipelineList from '../built-in-pipeline-list' +const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { + systemFeatures: { enable_marketplace: true }, +}) + vi.mock('../create-card', () => ({ default: () =>
CreateCard
, })) @@ -22,13 +28,6 @@ vi.mock('@/context/i18n', () => ({ useLocale: () => mockLocale, })) -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: vi.fn((selector) => { - const state = { systemFeatures: { enable_marketplace: true } } - return selector(state) - }), -})) - const mockUsePipelineTemplateList = vi.fn() vi.mock('@/service/use-pipeline', () => ({ usePipelineTemplateList: (...args: unknown[]) => mockUsePipelineTemplateList(...args), diff --git a/web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.tsx b/web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.tsx index 31c62758c1..3d14dd2f95 100644 --- a/web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.tsx +++ b/web/app/components/datasets/create-from-pipeline/list/built-in-pipeline-list.tsx @@ -1,7 +1,8 @@ +import { useSuspenseQuery } from '@tanstack/react-query' import { useMemo } from 'react' -import { useGlobalPublicStore } from '@/context/global-public-context' import { useLocale } from '@/context/i18n' import { LanguagesSupported } from '@/i18n-config/language' +import { systemFeaturesQueryOptions } from '@/service/system-features' import { usePipelineTemplateList } from '@/service/use-pipeline' import CreateCard from './create-card' import TemplateCard from './template-card' @@ -13,7 +14,10 @@ const BuiltInPipelineList = () => { return locale return LanguagesSupported[0] }, [locale]) - const enableMarketplace = useGlobalPublicStore(s => s.systemFeatures.enable_marketplace) + const { data: enableMarketplace } = useSuspenseQuery({ + ...systemFeaturesQueryOptions(), + select: s => s.enable_marketplace, + }) const { data: pipelineList, isLoading } = usePipelineTemplateList({ type: 'built-in', language }, enableMarketplace) const list = pipelineList?.pipeline_templates || [] diff --git a/web/app/components/datasets/list/__tests__/index.spec.tsx b/web/app/components/datasets/list/__tests__/index.spec.tsx index 37a787ff51..beee35b06d 100644 --- a/web/app/components/datasets/list/__tests__/index.spec.tsx +++ b/web/app/components/datasets/list/__tests__/index.spec.tsx @@ -1,7 +1,14 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { ReactElement } from '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 List from '../index' +let mockBrandingEnabled = false +const render = (ui: ReactElement) => renderWithSystemFeatures(ui, { + systemFeatures: { branding: { enabled: mockBrandingEnabled } }, +}) + const mockPush = vi.fn() const mockReplace = vi.fn() vi.mock('@/next/navigation', () => ({ @@ -20,15 +27,6 @@ vi.mock('@/context/app-context', () => ({ useSelector: () => true, })) -// Mock global public context -vi.mock('@/context/global-public-context', () => ({ - useGlobalPublicStore: () => ({ - systemFeatures: { - branding: { enabled: false }, - }, - }), -})) - // Mock external api panel context const mockSetShowExternalApiPanel = vi.fn() vi.mock('@/context/external-api-panel-context', () => ({ @@ -133,6 +131,7 @@ vi.mock('@/app/components/datasets/create/website/base/checkbox-with-label', () describe('List', () => { beforeEach(() => { vi.clearAllMocks() + mockBrandingEnabled = false }) describe('Rendering', () => { @@ -319,18 +318,9 @@ describe('List', () => { }) it('should not show DatasetFooter when branding is enabled', async () => { - vi.doMock('@/context/global-public-context', () => ({ - useGlobalPublicStore: () => ({ - systemFeatures: { - branding: { enabled: true }, - }, - }), - })) + mockBrandingEnabled = true - vi.resetModules() - const { default: ListComponent } = await import('../index') - - render() + render() expect(screen.queryByTestId('dataset-footer')).not.toBeInTheDocument() }) diff --git a/web/app/components/datasets/list/index.tsx b/web/app/components/datasets/list/index.tsx index 34be78ab3f..1f7eba63c6 100644 --- a/web/app/components/datasets/list/index.tsx +++ b/web/app/components/datasets/list/index.tsx @@ -1,10 +1,11 @@ 'use client' import { Button } from '@langgenius/dify-ui/button' +import { useSuspenseQuery } from '@tanstack/react-query' import { useBoolean, useDebounceFn } from 'ahooks' + // Libraries import { useState } from 'react' - import { useTranslation } from 'react-i18next' import Input from '@/app/components/base/input' import TagManagementModal from '@/app/components/base/tag-management' @@ -14,9 +15,9 @@ import { useStore as useTagStore } from '@/app/components/base/tag-management/st import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label' import { useAppContext, useSelector as useAppContextSelector } from '@/context/app-context' import { useExternalApiPanel } from '@/context/external-api-panel-context' -import { useGlobalPublicStore } from '@/context/global-public-context' import useDocumentTitle from '@/hooks/use-document-title' import { useDatasetApiBaseUrl } from '@/service/knowledge/use-dataset' +import { systemFeaturesQueryOptions } from '@/service/system-features' // Components import ExternalAPIPanel from '../external-api/external-api-panel' import ServiceApi from '../extra-info/service-api' @@ -25,7 +26,7 @@ import Datasets from './datasets' const List = () => { const { t } = useTranslation() - const { systemFeatures } = useGlobalPublicStore() + const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { isCurrentWorkspaceOwner } = useAppContext() const showTagManagementModal = useTagStore(s => s.showTagManagementModal) const { showExternalApiPanel, setShowExternalApiPanel } = useExternalApiPanel() diff --git a/web/app/components/devtools/react-scan/loader.tsx b/web/app/components/devtools/react-scan/loader.tsx index bd310f292f..8e933c2b24 100644 --- a/web/app/components/devtools/react-scan/loader.tsx +++ b/web/app/components/devtools/react-scan/loader.tsx @@ -9,7 +9,7 @@ export function ReactScanLoader() {