diff --git a/eslint-suppressions.json b/eslint-suppressions.json index a6e0d032c69..5207eae92bc 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2023,11 +2023,6 @@ "count": 2 } }, - "web/app/components/base/permission-selector/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/base/permission-selector/member-item.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -3230,14 +3225,6 @@ "count": 2 } }, - "web/app/components/develop/secret-key/secret-key-modal.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/explore/banner/__tests__/indicator-button.spec.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -4134,11 +4121,6 @@ "count": 1 } }, - "web/app/components/rag-pipeline/components/rag-pipeline-main.tsx": { - "ts/no-explicit-any": { - "count": 2 - } - }, "web/app/components/rag-pipeline/hooks/index.ts": { "no-barrel-files/no-barrel-files": { "count": 9 @@ -4427,11 +4409,6 @@ "count": 1 } }, - "web/app/components/workflow-app/hooks/use-workflow-init.ts": { - "ts/no-explicit-any": { - "count": 3 - } - }, "web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts": { "ts/no-explicit-any": { "count": 2 @@ -4457,11 +4434,6 @@ "count": 2 } }, - "web/app/components/workflow-app/index.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "web/app/components/workflow-app/store/workflow/workflow-slice.ts": { "ts/no-explicit-any": { "count": 2 @@ -6853,11 +6825,6 @@ "count": 1 } }, - "web/features/tag-management/components/tag-management-modal.tsx": { - "jsx-a11y/no-autofocus": { - "count": 1 - } - }, "web/hooks/use-async-window-open.spec.ts": { "ts/no-explicit-any": { "count": 6 diff --git a/web/__tests__/explore/explore-app-list-flow.test.tsx b/web/__tests__/explore/explore-app-list-flow.test.tsx index 74f0d989273..7776f2bbe99 100644 --- a/web/__tests__/explore/explore-app-list-flow.test.tsx +++ b/web/__tests__/explore/explore-app-list-flow.test.tsx @@ -10,7 +10,6 @@ import type { App } from '@/models/explore' import { fireEvent, screen, waitFor } from '@testing-library/react' import { createTestQueryClient, 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, fetchAppList, fetchBanners } from '@/service/explore' import { useMembers } from '@/service/use-common' import { AppModeEnum } from '@/types/app' @@ -124,6 +123,18 @@ vi.mock('@/context/app-context', () => ({ useSelector: (selector: (state: MockAppContext) => T): T => selector(mockUseAppContext()), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateAtomMock(importOriginal, () => mockUseAppContext()) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/service/use-common', () => ({ useMembers: vi.fn(), })) @@ -198,15 +209,15 @@ const createApp = (overrides: Partial = {}): App => ({ }) const mockMemberRole = (hasEditPermission: boolean) => { - ;(useAppContext as Mock).mockReturnValue({ + mockUseAppContext.mockReturnValue({ userProfile: { id: 'user-1' }, workspacePermissionKeys: hasEditPermission ? ['app.create_and_management'] : [], }) - ;(useMembers as Mock).mockReturnValue({ + vi.mocked(useMembers).mockReturnValue({ data: { accounts: [{ id: 'user-1', role: hasEditPermission ? 'admin' : 'normal' }], }, - }) + } as unknown as ReturnType) } const localeInput = { query: { language: 'en' } } diff --git a/web/app/components/app/create-app-modal/__tests__/index.spec.tsx b/web/app/components/app/create-app-modal/__tests__/index.spec.tsx index 3ab83834a5b..1e8266990e8 100644 --- a/web/app/components/app/create-app-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/create-app-modal/__tests__/index.spec.tsx @@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features' import { NEED_REFRESH_APP_LIST_KEY } from '@/app/components/apps/storage' -import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' import { useRouter } from '@/next/navigation' import { createApp } from '@/service/apps' @@ -21,6 +20,7 @@ const mockAppContextState = vi.hoisted(() => ({ userProfile: { id: 'user-1' }, workspacePermissionKeys: ['app.create_and_management'] as string[], })) +const mockUseAppContext = vi.hoisted(() => vi.fn()) vi.mock('ahooks', () => ({ useDebounceFn: unknown>(fn: T) => { @@ -75,7 +75,7 @@ vi.mock('@/context/provider-context', () => ({ useProviderContext: vi.fn(), })) vi.mock('@/context/app-context', () => ({ - useAppContext: vi.fn(), + useAppContext: mockUseAppContext, })) vi.mock('@/context/app-context-state', async (importOriginal) => { const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') @@ -100,7 +100,6 @@ const mockCreateApp = vi.mocked(createApp) const mockTrackCreateApp = vi.mocked(trackCreateApp) const mockGetRedirection = vi.mocked(getRedirection) const mockUseProviderContext = vi.mocked(useProviderContext) -const mockUseAppContext = vi.mocked(useAppContext) const { mockToastSuccess, mockToastError } = toastMocks const defaultPlanUsage = { @@ -148,7 +147,7 @@ describe('CreateAppModal', () => { mockUseAppContext.mockReturnValue({ userProfile: { id: 'user-1' }, workspacePermissionKeys: ['app.create_and_management'], - } as unknown as ReturnType) + }) mockAppContextState.userProfile = { id: 'user-1' } mockAppContextState.workspacePermissionKeys = ['app.create_and_management'] mockSetItem.mockClear() diff --git a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/tips.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/tips.spec.tsx index 2c212fe3fae..dc1c8d7621b 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/tips.spec.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/tips.spec.tsx @@ -1,30 +1,29 @@ -import type { AppContextValue } from '@/context/app-context' import { render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { useSelector } from '@/context/app-context' import Tips from '../tips' -// Mock AppContext's useSelector to control user profile data -vi.mock('@/context/app-context', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - useSelector: vi.fn(), - } +const mockAppContextState = vi.hoisted(() => ({ + userProfile: { + email: 'test@example.com', + }, +})) + +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateAtomMock(importOriginal, () => mockAppContextState) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateJotaiMock(importOriginal) }) describe('Tips', () => { - const mockEmail = 'test@example.com' - beforeEach(() => { vi.clearAllMocks() - vi.mocked(useSelector).mockImplementation((selector: (value: AppContextValue) => unknown) => { - return selector({ - userProfile: { - email: mockEmail, - }, - } as AppContextValue) - }) + mockAppContextState.userProfile.email = 'test@example.com' }) it('should render email tip in normal mode', () => { diff --git a/web/app/components/base/chat/chat/answer/human-input-content/tips.tsx b/web/app/components/base/chat/chat/answer/human-input-content/tips.tsx index 724b639fcde..ea4cb32ca75 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/tips.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/tips.tsx @@ -1,7 +1,8 @@ +import { useAtomValue } from 'jotai' import { memo } from 'react' import { Trans, useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' -import { useSelector as useAppSelector } from '@/context/app-context' +import { userProfileEmailAtom } from '@/context/app-context-state' type TipsProps = { showEmailTip: boolean @@ -15,7 +16,7 @@ const Tips = ({ showDebugModeTip, }: TipsProps) => { const { t } = useTranslation() - const email = useAppSelector(s => s.userProfile.email) + const email = useAtomValue(userProfileEmailAtom) return ( <> diff --git a/web/app/components/base/permission-selector/index.tsx b/web/app/components/base/permission-selector/index.tsx index fd4fca4e7ea..7d140ef5959 100644 --- a/web/app/components/base/permission-selector/index.tsx +++ b/web/app/components/base/permission-selector/index.tsx @@ -8,11 +8,13 @@ import { } from '@langgenius/dify-ui/popover' import { RiArrowDownSLine, RiGroup2Line, RiLock2Line } from '@remixicon/react' import { useDebounceFn } from 'ahooks' +import { useAtomValue } from 'jotai' import * as React from 'react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +// eslint-disable-next-line no-restricted-imports -- This legacy selector still relies on showLeftIcon/showClearIcon props from the old input. import Input from '@/app/components/base/input' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { userProfileAtom } from '@/context/app-context-state' import { PermissionLevel } from '@/models/permission' import MemberItem from './member-item' import Item from './permission-item' @@ -45,7 +47,7 @@ const PermissionSelector = ({ hidePartialMembers = false, }: PermissionSelectorProps) => { const { t } = useTranslation() - const userProfile = useAppContextWithSelector(state => state.userProfile) + const userProfile = useAtomValue(userProfileAtom) const [open, setOpen] = useState(false) const [keywords, setKeywords] = useState('') @@ -72,7 +74,7 @@ const PermissionSelector = ({ }, [userProfile, value, memberList]) const showMe = useMemo(() => { - return userProfile.name.includes(searchKeywords) || userProfile.email.includes(searchKeywords) + return (userProfile.name ?? '').includes(searchKeywords) || (userProfile.email ?? '').includes(searchKeywords) }, [searchKeywords, userProfile]) const filteredMemberList = useMemo(() => { diff --git a/web/app/components/base/user-avatar-list/index.tsx b/web/app/components/base/user-avatar-list/index.tsx index c1bb340a41e..c06c14f8b50 100644 --- a/web/app/components/base/user-avatar-list/index.tsx +++ b/web/app/components/base/user-avatar-list/index.tsx @@ -1,9 +1,10 @@ import type { AvatarSize } from '@langgenius/dify-ui/avatar' import type { FC } from 'react' import { AvatarFallback, AvatarImage, AvatarRoot } from '@langgenius/dify-ui/avatar' +import { useAtomValue } from 'jotai' import { memo } from 'react' import { getUserColor } from '@/app/components/workflow/collaboration/utils/user-color' -import { useAppContext } from '@/context/app-context' +import { userProfileIdAtom } from '@/context/app-context-state' type User = { id: string @@ -37,7 +38,7 @@ export const UserAvatarList: FC = memo(({ className = '', showCount = true, }) => { - const { userProfile } = useAppContext() + const currentUserId = useAtomValue(userProfileIdAtom) if (!users.length) return null @@ -46,8 +47,6 @@ export const UserAvatarList: FC = memo(({ const visibleUsers = users.slice(0, actualMaxVisible) const remainingCount = users.length - actualMaxVisible - const currentUserId = userProfile?.id - return (
{visibleUsers.map((user, index) => { diff --git a/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx b/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx index 72a35eb6843..790fba4a4e4 100644 --- a/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx +++ b/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx @@ -7,11 +7,11 @@ import type { ICurrentWorkspace, LangGeniusVersionResponse } from '@/models/comm import { render, screen } from '@testing-library/react' import { Plan } from '@/app/components/billing/type' import { mailToSupport } from '@/app/components/header/utils/util' -import { useAppContext } from '@/context/app-context' import { baseProviderContextValue, useProviderContext } from '@/context/provider-context' import AppsFull from '../index' let mockAppContextState: AppContextValue +const mockUseAppContext = vi.hoisted(() => vi.fn()) vi.mock('@/config', async (importOriginal) => { const actual = await importOriginal() @@ -22,7 +22,7 @@ vi.mock('@/config', async (importOriginal) => { }) vi.mock('@/context/app-context', () => ({ - useAppContext: vi.fn(), + useAppContext: mockUseAppContext, })) vi.mock('@/context/app-context-state', async (importOriginal) => { @@ -136,7 +136,7 @@ describe('AppsFull', () => { vi.clearAllMocks() ;(useProviderContext as Mock).mockReturnValue(buildProviderContext()) mockAppContextState = buildAppContext() - ;(useAppContext as Mock).mockReturnValue(mockAppContextState) + mockUseAppContext.mockReturnValue(mockAppContextState) ;(mailToSupport as Mock).mockReturnValue('mailto:support@example.com') }) diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx index 05e2e76adc7..d0e874cd9e0 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx @@ -2,7 +2,6 @@ import type { Mock } from 'vitest' import { toast, ToastHost } from '@langgenius/dify-ui/toast' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import * as React from 'react' -import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import { fetchSubscriptionUrls } from '@/service/billing' @@ -13,9 +12,10 @@ import { PlanRange } from '../../../plan-switcher/plan-range-switcher' import CloudPlanItem from '../index' let mockAppCtx: Record = {} +const mockUseAppContext = vi.hoisted(() => vi.fn()) vi.mock('@/context/app-context', () => ({ - useAppContext: vi.fn(), + useAppContext: mockUseAppContext, })) vi.mock('@/context/app-context-state', async (importOriginal) => { @@ -56,7 +56,6 @@ vi.mock('../../../assets', () => ({ Team: () =>
Team Icon
, })) -const mockUseAppContext = useAppContext as Mock const mockUseProviderContext = useProviderContext as Mock const mockUseAsyncWindowOpen = useAsyncWindowOpen as Mock const mockBillingInvoices = consoleClient.billing.invoices.get as Mock diff --git a/web/app/components/billing/pricing/plans/self-hosted-plan-item/__tests__/index.spec.tsx b/web/app/components/billing/pricing/plans/self-hosted-plan-item/__tests__/index.spec.tsx index 11c57d8ab67..99c1643d68d 100644 --- a/web/app/components/billing/pricing/plans/self-hosted-plan-item/__tests__/index.spec.tsx +++ b/web/app/components/billing/pricing/plans/self-hosted-plan-item/__tests__/index.spec.tsx @@ -1,13 +1,12 @@ -import type { Mock } from 'vitest' import { toast, ToastHost } from '@langgenius/dify-ui/toast' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -import { useAppContext } from '@/context/app-context' import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '../../../../config' import { SelfHostedPlan } from '../../../../type' import SelfHostedPlanItem from '../index' let mockAppCtx: Record = {} +const mockUseAppContext = vi.hoisted(() => vi.fn()) vi.mock('../list', () => ({ default: ({ plan }: { plan: string }) => ( @@ -19,7 +18,7 @@ vi.mock('../list', () => ({ })) vi.mock('@/context/app-context', () => ({ - useAppContext: vi.fn(), + useAppContext: mockUseAppContext, })) vi.mock('@/context/app-context-state', async (importOriginal) => { @@ -40,8 +39,6 @@ vi.mock('../../../assets', () => ({ EnterpriseNoise: () =>
EnterpriseNoise
, })) -const mockUseAppContext = useAppContext as Mock - let assignedHref = '' const originalLocation = window.location 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 fdfa4377bea..e4bbaca971c 100644 --- a/web/app/components/custom/custom-page/__tests__/index.spec.tsx +++ b/web/app/components/custom/custom-page/__tests__/index.spec.tsx @@ -10,7 +10,6 @@ import { Plan } from '@/app/components/billing/type' import { initialLangGeniusVersionInfo, initialWorkspaceInfo, - useAppContext, userProfilePlaceholder, } from '@/context/app-context' import { useModalContext } from '@/context/modal-context' @@ -46,6 +45,7 @@ const { mockToast } = vi.hoisted(() => { }) return { mockToast } }) +const mockUseAppContext = vi.hoisted(() => vi.fn()) vi.mock('@/context/provider-context', () => ({ useProviderContext: vi.fn(), @@ -57,7 +57,7 @@ vi.mock('@/context/app-context', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - useAppContext: vi.fn(), + useAppContext: mockUseAppContext, } }) vi.mock('@langgenius/dify-ui/toast', () => ({ @@ -66,7 +66,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ const mockUseProviderContext = vi.mocked(useProviderContext) const mockUseModalContext = vi.mocked(useModalContext) -const mockUseAppContext = vi.mocked(useAppContext) const createProviderContext = ({ enableBilling = false, 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 1a096a38dc5..058b381b323 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 @@ -11,7 +11,6 @@ import { Plan } from '@/app/components/billing/type' import { initialLangGeniusVersionInfo, initialWorkspaceInfo, - useAppContext, userProfilePlaceholder, } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' @@ -43,6 +42,10 @@ const { mockNotify, mockToast } = vi.hoisted(() => { }) return { mockNotify, mockToast } }) +const appContextStateRef = vi.hoisted(() => ({ + value: undefined as AppContextValue | undefined, +})) +const mockUseAppContext = vi.hoisted(() => vi.fn()) vi.mock('@langgenius/dify-ui/toast', () => ({ toast: mockToast, @@ -54,9 +57,22 @@ vi.mock('@/context/app-context', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - useAppContext: vi.fn(), + useAppContext: mockUseAppContext, } }) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateAtomMock(importOriginal, () => ({ + ...appContextStateRef.value, + refreshCurrentWorkspace: appContextStateRef.value?.mutateCurrentWorkspace, + })) +}) +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateJotaiMock(importOriginal) +}) vi.mock('@/context/provider-context', () => ({ useProviderContext: vi.fn(), })) @@ -66,7 +82,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 mockImageUpload = vi.mocked(imageUpload) const mockGetImageUploadErrorMessage = vi.mocked(getImageUploadErrorMessage) @@ -120,11 +135,15 @@ const createAppContextValue = (overrides: Partial = {}): AppCon describe('useWebAppBrand', () => { let appContextValue: AppContextValue + const setAppContextValue = (nextValue: AppContextValue) => { + appContextValue = nextValue + appContextStateRef.value = nextValue + } beforeEach(() => { vi.clearAllMocks() - appContextValue = createAppContextValue() + setAppContextValue(createAppContextValue()) currentBrandingOverrides = {} mockUpdateCurrentWorkspace.mockResolvedValue(appContextValue.currentWorkspace) @@ -146,10 +165,10 @@ describe('useWebAppBrand', () => { }) it('should disable uploads when customization management permission is missing', () => { - appContextValue = createAppContextValue({ + setAppContextValue(createAppContextValue({ workspacePermissionKeys: [], isCurrentWorkspaceManager: true, - }) + })) const { result } = renderHook(() => useWebAppBrand()) @@ -158,10 +177,10 @@ describe('useWebAppBrand', () => { }) it('should allow uploads for non-manager users with customization management permission', () => { - appContextValue = createAppContextValue({ + setAppContextValue(createAppContextValue({ workspacePermissionKeys: ['customization.manage'], isCurrentWorkspaceManager: false, - }) + })) const { result } = renderHook(() => useWebAppBrand()) @@ -174,7 +193,7 @@ describe('useWebAppBrand', () => { enableBilling: true, planType: Plan.sandbox, })) - appContextValue = createAppContextValue({ + setAppContextValue(createAppContextValue({ currentWorkspace: { ...initialWorkspaceInfo, custom_config: { @@ -182,7 +201,7 @@ describe('useWebAppBrand', () => { remove_webapp_brand: true, }, }, - }) + })) const { result } = renderHook(() => useWebAppBrand()) @@ -200,12 +219,12 @@ describe('useWebAppBrand', () => { }) it('should fall back to an empty custom logo when custom config is missing', () => { - appContextValue = { + setAppContextValue({ ...createAppContextValue(), currentWorkspace: { ...initialWorkspaceInfo, }, - } + }) const { result } = renderHook(() => useWebAppBrand()) @@ -308,9 +327,9 @@ describe('useWebAppBrand', () => { it('should persist the selected logo and reset transient state on apply', async () => { const mutateCurrentWorkspace = vi.fn() - appContextValue = createAppContextValue({ + setAppContextValue(createAppContextValue({ mutateCurrentWorkspace, - }) + })) mockImageUpload.mockImplementation(({ onSuccessCallback }) => { onSuccessCallback({ id: 'new-logo' }) }) @@ -345,9 +364,9 @@ describe('useWebAppBrand', () => { it('should restore the default branding configuration', async () => { const mutateCurrentWorkspace = vi.fn() - appContextValue = createAppContextValue({ + setAppContextValue(createAppContextValue({ mutateCurrentWorkspace, - }) + })) const { result } = renderHook(() => useWebAppBrand()) @@ -367,9 +386,9 @@ describe('useWebAppBrand', () => { it('should persist brand removal changes', async () => { const mutateCurrentWorkspace = vi.fn() - appContextValue = createAppContextValue({ + setAppContextValue(createAppContextValue({ mutateCurrentWorkspace, - }) + })) 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 397334bb2ec..fa6d0348d18 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,11 +1,12 @@ import type { ChangeEvent } from 'react' import { toast } from '@langgenius/dify-ui/toast' import { useSuspenseQuery } from '@tanstack/react-query' +import { useAtomValue, useSetAtom } from 'jotai' 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 { currentWorkspaceAtom, refreshCurrentWorkspaceAtom, workspacePermissionKeysAtom } from '@/context/app-context-state' import { useProviderContext } from '@/context/provider-context' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { updateCurrentWorkspace } from '@/service/common' @@ -17,7 +18,9 @@ const WEB_APP_LOGO_UPLOAD_URL = '/workspaces/custom-config/webapp-logo/upload' const useWebAppBrand = () => { const { t } = useTranslation() const { plan, enableBilling } = useProviderContext() - const { currentWorkspace, mutateCurrentWorkspace, workspacePermissionKeys } = useAppContext() + const currentWorkspace = useAtomValue(currentWorkspaceAtom) + const mutateCurrentWorkspace = useSetAtom(refreshCurrentWorkspaceAtom) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const [fileId, setFileId] = useState('') const [imgKey, setImgKey] = useState(() => Date.now()) const [uploadProgress, setUploadProgress] = useState(0) diff --git a/web/app/components/develop/index.tsx b/web/app/components/develop/index.tsx index 3e3b52a5a39..7696bc6fca6 100644 --- a/web/app/components/develop/index.tsx +++ b/web/app/components/develop/index.tsx @@ -1,9 +1,10 @@ 'use client' +import { useAtomValue } from 'jotai' import { useStore as useAppStore } from '@/app/components/app/store' import Loading from '@/app/components/base/loading' import ApiServer from '@/app/components/develop/ApiServer' import Doc from '@/app/components/develop/doc' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { userProfileIdAtom, workspacePermissionKeysAtom } from '@/context/app-context-state' import { getAppACLCapabilities } from '@/utils/permission' type IDevelopMainProps = { @@ -12,8 +13,8 @@ type IDevelopMainProps = { const DevelopMain = ({ appId }: IDevelopMainProps) => { const appDetail = useAppStore(state => state.appDetail) - const currentUserId = useAppContextWithSelector(state => state.userProfile?.id) - const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const currentUserId = useAtomValue(userProfileIdAtom) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const appACLCapabilities = getAppACLCapabilities(appDetail?.permission_keys, { currentUserId, resourceMaintainer: appDetail?.maintainer, diff --git a/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx b/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx index 94c215103c6..206af53a0cd 100644 --- a/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx +++ b/web/app/components/develop/secret-key/__tests__/secret-key-modal.spec.tsx @@ -35,6 +35,22 @@ vi.mock('@/context/app-context', () => ({ }), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateAtomMock(importOriginal, () => ({ + currentWorkspace: mockCurrentWorkspace(), + isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager(), + isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(), + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/hooks/use-timestamp', () => ({ default: () => ({ formatTime: vi.fn((value: number, _format: string) => `Formatted: ${value}`), @@ -357,7 +373,7 @@ describe('SecretKeyModal', () => { }) it('should disable create button when no workspace', async () => { - mockCurrentWorkspace.mockReturnValue(null) + mockCurrentWorkspace.mockReturnValue({ id: '', name: '' }) await renderModal() const createButton = screen.getByText('appApi.apiKeyModal.createNewSecretKey').closest('button') diff --git a/web/app/components/develop/secret-key/secret-key-modal.tsx b/web/app/components/develop/secret-key/secret-key-modal.tsx index 13c16582e61..ab56b24f832 100644 --- a/web/app/components/develop/secret-key/secret-key-modal.tsx +++ b/web/app/components/develop/secret-key/secret-key-modal.tsx @@ -12,6 +12,7 @@ import { import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' +import { useAtomValue } from 'jotai' import { useState, } from 'react' @@ -19,7 +20,7 @@ import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import CopyFeedback from '@/app/components/base/copy-feedback' import Loading from '@/app/components/base/loading' -import { useAppContext } from '@/context/app-context' +import { currentWorkspaceAtom } from '@/context/app-context-state' import useTimestamp from '@/hooks/use-timestamp' import { createApikey as createAppApikey, @@ -49,7 +50,7 @@ const SecretKeyModal = ({ }: ISecretKeyModalProps) => { const { t } = useTranslation() const { formatTime } = useTimestamp() - const { currentWorkspace } = useAppContext() + const currentWorkspace = useAtomValue(currentWorkspaceAtom) const [showConfirmDelete, setShowConfirmDelete] = useState(false) const [isVisible, setIsVisible] = useState(false) const [newKey, setNewKey] = useState(undefined) @@ -81,7 +82,7 @@ const SecretKeyModal = ({ } const onCreate = async () => { - if (!currentWorkspace || !canManage) + if (!currentWorkspace.id || !canManage) return const params = appId @@ -129,7 +130,14 @@ const SecretKeyModal = ({
- +

{t('apiKeyModal.apiSecretKeyTips', { ns: 'appApi' })}

{isApiKeysLoading &&
} @@ -168,7 +176,7 @@ const SecretKeyModal = ({ ) }
- diff --git a/web/app/components/explore/__tests__/index.spec.tsx b/web/app/components/explore/__tests__/index.spec.tsx index ba7cd580baa..2e3065f1fc4 100644 --- a/web/app/components/explore/__tests__/index.spec.tsx +++ b/web/app/components/explore/__tests__/index.spec.tsx @@ -1,6 +1,4 @@ -import type { Mock } from 'vitest' -import { render, screen, waitFor } from '@testing-library/react' -import { useAppContext } from '@/context/app-context' +import { render, screen } from '@testing-library/react' import { MediaType } from '@/hooks/use-breakpoints' import Explore from '../index' @@ -42,17 +40,10 @@ vi.mock('@/service/use-explore', () => ({ }), })) -vi.mock('@/context/app-context', () => ({ - useAppContext: vi.fn(), -})) - describe('Explore', () => { beforeEach(() => { vi.clearAllMocks() mockMediaType = MediaType.pc - ;(useAppContext as Mock).mockReturnValue({ - isCurrentWorkspaceDatasetOperator: false, - }) }) describe('Rendering', () => { @@ -90,23 +81,19 @@ describe('Explore', () => { }) describe('Effects', () => { - it('should not redirect dataset operators at component level', async () => { - ;(useAppContext as Mock).mockReturnValue({ - isCurrentWorkspaceDatasetOperator: true, - }) - + it('should not redirect at component level', () => { render((
child
)) - await waitFor(() => { - expect(mockReplace).not.toHaveBeenCalled() - }) + expect(mockReplace).not.toHaveBeenCalled() }) - it('should not redirect non dataset operators', () => { + it('should not redirect on mobile', () => { + mockMediaType = MediaType.mobile + render((
child
diff --git a/web/app/components/explore/app-list/__tests__/index.spec.tsx b/web/app/components/explore/app-list/__tests__/index.spec.tsx index 8c9f34777de..3dcdd5d18a4 100644 --- a/web/app/components/explore/app-list/__tests__/index.spec.tsx +++ b/web/app/components/explore/app-list/__tests__/index.spec.tsx @@ -7,7 +7,6 @@ import type { App as WorkspaceApp } from '@/types/app' import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { createStore, Provider as JotaiProvider } from 'jotai' import { createSystemFeaturesWrapper } from '@/__tests__/utils/mock-system-features' -import { useAppContext } from '@/context/app-context' import { fetchAppDetail, fetchAppList, fetchBanners } from '@/service/explore' import { renderWithNuqs } from '@/test/nuqs-testing' import { AppModeEnum } from '@/types/app' @@ -15,12 +14,10 @@ import { AppACLPermission } from '@/utils/permission' import { LEARN_DIFY_HIDDEN_STORAGE_KEY } from '../../learn-dify/storage' import AppList from '../index' -type MockAppContext = { - userProfile: { id: string } - workspacePermissionKeys: string[] -} - -const mockUseAppContext = vi.hoisted(() => vi.fn<() => MockAppContext>()) +const mockAppContextState = vi.hoisted(() => ({ + userProfile: { id: 'user-1' }, + workspacePermissionKeys: [] as string[], +})) let mockExploreData: { categories: string[], allList: App[] } | undefined = { categories: [], allList: [] } let mockLearnDifyApps: App[] = [] @@ -127,10 +124,17 @@ vi.mock('@/service/client', () => ({ }, })) -vi.mock('@/context/app-context', () => ({ - useAppContext: mockUseAppContext, - useSelector: (selector: (state: MockAppContext) => T): T => selector(mockUseAppContext()), -})) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateAtomMock(importOriginal, () => mockAppContextState) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateJotaiMock(importOriginal) +}) vi.mock('@/hooks/use-import-dsl', () => ({ useImportDSL: () => ({ @@ -282,10 +286,7 @@ const createBanner = (overrides: Partial = {}): BannerType => ({ }) const mockAppCreatePermission = (hasEditPermission: boolean) => { - ;(useAppContext as Mock).mockReturnValue({ - userProfile: { id: 'user-1' }, - workspacePermissionKeys: hasEditPermission ? ['app.create_and_management'] : [], - }) + mockAppContextState.workspacePermissionKeys = hasEditPermission ? ['app.create_and_management'] : [] } type RenderOptions = { diff --git a/web/app/components/explore/app-list/index.tsx b/web/app/components/explore/app-list/index.tsx index 7679124c611..27f2d383399 100644 --- a/web/app/components/explore/app-list/index.tsx +++ b/web/app/components/explore/app-list/index.tsx @@ -9,6 +9,7 @@ import type { TrackCreateAppParams } from '@/utils/create-app-tracking' import { cn } from '@langgenius/dify-ui/cn' import { queryOptions, useQueries, useSuspenseQuery } from '@tanstack/react-query' import { useDebounceFn } from 'ahooks' +import { useAtomValue } from 'jotai' import { useQueryState } from 'nuqs' import * as React from 'react' import { useCallback, useMemo, useRef, useState } from 'react' @@ -17,7 +18,7 @@ import DSLConfirmModal from '@/app/components/app/create-from-dsl-modal/dsl-conf import AppCard from '@/app/components/explore/app-card' import Banner from '@/app/components/explore/banner/banner' import CreateAppModal from '@/app/components/explore/create-app-modal' -import { useAppContext } from '@/context/app-context' +import { workspacePermissionKeysAtom } from '@/context/app-context-state' import { useLocale } from '@/context/i18n' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useImportDSL } from '@/hooks/use-import-dsl' @@ -101,7 +102,7 @@ function getDisabledBannersQueryOptions() { const Apps = ({ onSuccess }: { onSuccess?: () => void }) => { const { t } = useTranslation() const locale = useLocale() - const { workspacePermissionKeys } = useAppContext() + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const { data: systemFeatures } = useSuspenseQuery( systemFeaturesQueryOptions(), ) diff --git a/web/app/components/explore/banner/__tests__/banner.spec.tsx b/web/app/components/explore/banner/__tests__/banner.spec.tsx index 14573252965..2be6855602a 100644 --- a/web/app/components/explore/banner/__tests__/banner.spec.tsx +++ b/web/app/components/explore/banner/__tests__/banner.spec.tsx @@ -6,10 +6,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import Banner from '../banner' const mockUseGetBanners = vi.fn() -const mockUseSelector = vi.fn() const mockTrackEvent = vi.fn() let mockSelectedIndex = 0 const mockCarouselListeners = new Set<() => void>() +const mockAppContextState = vi.hoisted(() => ({ + userProfile: { + id: 'account-123', + name: 'Evan', + }, +})) const setMockSelectedIndex = (index: number) => { mockSelectedIndex = index @@ -20,9 +25,17 @@ vi.mock('@/context/i18n', () => ({ useLocale: () => 'en-US', })) -vi.mock('@/context/app-context', () => ({ - useSelector: (...args: unknown[]) => mockUseSelector(...args), -})) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateAtomMock(importOriginal, () => mockAppContextState) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateJotaiMock(importOriginal) +}) vi.mock('@/app/components/base/amplitude', () => ({ trackEvent: (...args: unknown[]) => mockTrackEvent(...args), @@ -133,12 +146,10 @@ describe('Banner', () => { vi.useFakeTimers() mockSelectedIndex = 0 mockCarouselListeners.clear() - mockUseSelector.mockImplementation(selector => selector({ - userProfile: { - id: 'account-123', - name: 'Evan', - }, - })) + mockAppContextState.userProfile = { + id: 'account-123', + name: 'Evan', + } }) afterEach(() => { @@ -357,12 +368,10 @@ describe('Banner', () => { }) it('does not track impressions when account id is unavailable', () => { - mockUseSelector.mockImplementation(selector => selector({ - userProfile: { - id: '', - name: '', - }, - })) + mockAppContextState.userProfile = { + id: '', + name: '', + } mockUseGetBanners.mockReturnValue({ data: [createMockBanner('1', 'enabled', 'Enabled Banner 1')], isLoading: false, diff --git a/web/app/components/explore/banner/banner.tsx b/web/app/components/explore/banner/banner.tsx index 32efb3ddc01..259ae66ea1c 100644 --- a/web/app/components/explore/banner/banner.tsx +++ b/web/app/components/explore/banner/banner.tsx @@ -1,10 +1,11 @@ import type { Banner as BannerType } from '@/models/app' +import { useAtomValue } from 'jotai' import * as React from 'react' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { trackEvent } from '@/app/components/base/amplitude' import { Carousel, useCarousel } from '@/app/components/base/carousel' -import { useSelector } from '@/context/app-context' +import { userProfileAtom } from '@/context/app-context-state' import { useLocale } from '@/context/i18n' import { BannerItem } from './banner-item' @@ -59,8 +60,9 @@ function Banner({ }: BannerProps) { const { t } = useTranslation() const locale = useLocale() - const accountId = useSelector(s => s.userProfile.id) - const userName = useSelector(s => s.userProfile.name) + const userProfile = useAtomValue(userProfileAtom) + const accountId = userProfile.id + const userName = userProfile.name const [isHovered, setIsHovered] = useState(false) const [isResizing, setIsResizing] = useState(false) const resizeTimerRef = useRef(null) diff --git a/web/app/components/explore/continue-work/__tests__/item.spec.tsx b/web/app/components/explore/continue-work/__tests__/item.spec.tsx index 275ee584993..73f4866b580 100644 --- a/web/app/components/explore/continue-work/__tests__/item.spec.tsx +++ b/web/app/components/explore/continue-work/__tests__/item.spec.tsx @@ -7,7 +7,7 @@ import { AppModeEnum } from '@/types/app' import { AppACLPermission } from '@/utils/permission' import ContinueWorkItem from '../item' -const mockAppContext = vi.hoisted(() => ({ +const mockAppContextState = vi.hoisted(() => ({ userProfile: { id: 'user-1' }, workspacePermissionKeys: ['app.create_and_management'], })) @@ -18,9 +18,17 @@ const toastMocks = vi.hoisted(() => ({ warning: vi.fn(), })) -vi.mock('@/context/app-context', () => ({ - useSelector: (selector: (state: typeof mockAppContext) => unknown) => selector(mockAppContext), -})) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateAtomMock(importOriginal, () => mockAppContextState) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateJotaiMock(importOriginal) +}) vi.mock('@/hooks/use-format-time-from-now', () => ({ useFormatTimeFromNow: () => ({ @@ -82,8 +90,8 @@ const renderItem = ( describe('ContinueWorkItem', () => { beforeEach(() => { vi.clearAllMocks() - mockAppContext.userProfile = { id: 'user-1' } - mockAppContext.workspacePermissionKeys = ['app.create_and_management'] + mockAppContextState.userProfile = { id: 'user-1' } + mockAppContextState.workspacePermissionKeys = ['app.create_and_management'] mockFormatTimeFromNow.mockReturnValue('5 minutes ago') }) diff --git a/web/app/components/explore/continue-work/item.tsx b/web/app/components/explore/continue-work/item.tsx index 570dae473c1..da956642d26 100644 --- a/web/app/components/explore/continue-work/item.tsx +++ b/web/app/components/explore/continue-work/item.tsx @@ -4,11 +4,12 @@ import type { App } from '@/types/app' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' import { useSuspenseQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' import * as React from 'react' import { useTranslation } from 'react-i18next' import { AppTypeIcon } from '@/app/components/app/type-selector' import AppIcon from '@/app/components/base/app-icon' -import { useSelector as useAppContextSelector } from '@/context/app-context' +import { userProfileIdAtom, workspacePermissionKeysAtom } from '@/context/app-context-state' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import Link from '@/next/link' @@ -24,8 +25,8 @@ const ContinueWorkItem = ({ }: ContinueWorkItemProps) => { const { t } = useTranslation() const { formatTimeFromNow } = useFormatTimeFromNow() - const currentUserId = useAppContextSelector(state => state.userProfile?.id) - const workspacePermissionKeys = useAppContextSelector(state => state.workspacePermissionKeys) + const currentUserId = useAtomValue(userProfileIdAtom) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const isRbacEnabled = systemFeatures.rbac_enabled const updatedAt = (app.updated_at || app.created_at) * 1000 diff --git a/web/app/components/header/account-dropdown/__tests__/index.spec.tsx b/web/app/components/header/account-dropdown/__tests__/index.spec.tsx index 64065d65e15..051b5eca20a 100644 --- a/web/app/components/header/account-dropdown/__tests__/index.spec.tsx +++ b/web/app/components/header/account-dropdown/__tests__/index.spec.tsx @@ -6,7 +6,6 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { Plan } from '@/app/components/billing/type' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' -import { useAppContext } from '@/context/app-context' import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' import { useRouter } from '@/next/navigation' @@ -46,6 +45,7 @@ const { mockSetTheme } = vi.hoisted(() => ({ const mockAppContextState = vi.hoisted(() => ({ current: undefined as AppContextValue | undefined, })) +const mockUseAppContext = vi.hoisted(() => vi.fn()) vi.mock('next-themes', () => ({ useTheme: () => ({ @@ -55,7 +55,7 @@ vi.mock('next-themes', () => ({ })) vi.mock('@/context/app-context', () => ({ - useAppContext: vi.fn(), + useAppContext: mockUseAppContext, })) vi.mock('@/context/app-context-state', async (importOriginal) => { @@ -166,7 +166,7 @@ const baseAppContextValue: AppContextValue = { const setAppContextValue = (value: AppContextValue) => { mockAppContextState.current = value - vi.mocked(useAppContext).mockReturnValue(value) + mockUseAppContext.mockReturnValue(value) } describe('AccountDropdown', () => { diff --git a/web/app/components/header/account-setting/__tests__/index.spec.tsx b/web/app/components/header/account-setting/__tests__/index.spec.tsx index b4635473532..5f0fc462f2b 100644 --- a/web/app/components/header/account-setting/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/__tests__/index.spec.tsx @@ -3,7 +3,6 @@ import type { AppContextValue } from '@/context/app-context' import { fireEvent, screen } from '@testing-library/react' import { useState } from 'react' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' -import { useAppContext } from '@/context/app-context' import { baseProviderContextValue, useProviderContext } from '@/context/provider-context' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import { ACCOUNT_SETTING_TAB } from '../constants' @@ -13,6 +12,7 @@ const mockResetModelProviderListExpanded = vi.fn() const mockAppContextState = vi.hoisted(() => ({ current: null as unknown, })) +const mockUseAppContext = vi.hoisted(() => vi.fn()) vi.mock('@/context/provider-context', async (importOriginal) => { const actual = await importOriginal() @@ -26,7 +26,7 @@ vi.mock('@/context/app-context', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - useAppContext: vi.fn(), + useAppContext: mockUseAppContext, useSelector: vi.fn((selector: (state: unknown) => unknown) => selector(mockAppContextState.current)), } }) @@ -239,7 +239,7 @@ describe('AccountSetting', () => { enableBilling: true, enableReplaceWebAppLogo: true, }) - vi.mocked(useAppContext).mockReturnValue(baseAppContextValue) + mockUseAppContext.mockReturnValue(baseAppContextValue) mockAppContextState.current = baseAppContextValue vi.mocked(useBreakpoints).mockReturnValue(MediaType.pc) }) @@ -362,7 +362,7 @@ describe('AccountSetting', () => { ...baseAppContextValue, isCurrentWorkspaceDatasetOperator: true, } - vi.mocked(useAppContext).mockReturnValue(datasetOperatorContext) + mockUseAppContext.mockReturnValue(datasetOperatorContext) mockAppContextState.current = datasetOperatorContext // Act @@ -383,7 +383,7 @@ describe('AccountSetting', () => { ...baseAppContextValue, workspacePermissionKeys: baseAppContextValue.workspacePermissionKeys.filter(key => key !== 'api_extension.manage'), } - vi.mocked(useAppContext).mockReturnValue(contextWithoutApiExtensionPermission) + mockUseAppContext.mockReturnValue(contextWithoutApiExtensionPermission) mockAppContextState.current = contextWithoutApiExtensionPermission // Act @@ -401,7 +401,7 @@ describe('AccountSetting', () => { ...baseAppContextValue, workspacePermissionKeys: baseAppContextValue.workspacePermissionKeys.filter(key => key !== 'customization.manage'), } - vi.mocked(useAppContext).mockReturnValue(contextWithoutCustomizationPermission) + mockUseAppContext.mockReturnValue(contextWithoutCustomizationPermission) mockAppContextState.current = contextWithoutCustomizationPermission // Act @@ -420,7 +420,7 @@ describe('AccountSetting', () => { ...baseAppContextValue, workspacePermissionKeys: baseAppContextValue.workspacePermissionKeys.filter(key => key !== 'workspace.role.manage'), } - vi.mocked(useAppContext).mockReturnValue(contextWithoutRoleManagePermission) + mockUseAppContext.mockReturnValue(contextWithoutRoleManagePermission) mockAppContextState.current = contextWithoutRoleManagePermission // Act @@ -505,7 +505,7 @@ describe('AccountSetting', () => { ...baseAppContextValue, workspacePermissionKeys: baseAppContextValue.workspacePermissionKeys.filter(key => key !== 'billing.view'), } - vi.mocked(useAppContext).mockReturnValue(contextWithoutBillingViewPermission) + mockUseAppContext.mockReturnValue(contextWithoutBillingViewPermission) mockAppContextState.current = contextWithoutBillingViewPermission // Act @@ -521,7 +521,7 @@ describe('AccountSetting', () => { ...baseAppContextValue, workspacePermissionKeys: baseAppContextValue.workspacePermissionKeys.filter(key => key !== 'billing.view'), } - vi.mocked(useAppContext).mockReturnValue(contextWithoutBillingViewPermission) + mockUseAppContext.mockReturnValue(contextWithoutBillingViewPermission) mockAppContextState.current = contextWithoutBillingViewPermission // Act diff --git a/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx index d93d0c10571..f1544cb097b 100644 --- a/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/__tests__/index.spec.tsx @@ -7,7 +7,6 @@ import { vi } from 'vitest' import { createMockProviderContextValue } from '@/__mocks__/provider-context' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' import { Plan } from '@/app/components/billing/type' -import { useAppContext } from '@/context/app-context' import { useProviderContext } from '@/context/provider-context' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import { useUpdateRolesOfMember } from '@/service/access-control/use-member-roles' @@ -17,8 +16,11 @@ import MembersPage from '../index' const mockAppContextState = vi.hoisted(() => ({ current: {} as Partial, })) +const mockUseAppContext = vi.hoisted(() => vi.fn()) -vi.mock('@/context/app-context') +vi.mock('@/context/app-context', () => ({ + useAppContext: mockUseAppContext, +})) vi.mock('@/context/app-context-state', async (importOriginal) => { const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current) @@ -55,7 +57,7 @@ const createRole = (overrides: Partial): Role => ({ const setAppContextValue = (value: AppContextValue) => { mockAppContextState.current = value - vi.mocked(useAppContext).mockReturnValue(value) + mockUseAppContext.mockReturnValue(value) } vi.mock('../edit-workspace-modal', () => ({ diff --git a/web/app/components/header/account-setting/members-page/__tests__/invite-button.spec.tsx b/web/app/components/header/account-setting/members-page/__tests__/invite-button.spec.tsx index 7ef5e69bacd..10e50fc885e 100644 --- a/web/app/components/header/account-setting/members-page/__tests__/invite-button.spec.tsx +++ b/web/app/components/header/account-setting/members-page/__tests__/invite-button.spec.tsx @@ -3,11 +3,14 @@ import type { ICurrentWorkspace } from '@/models/common' import { screen } from '@testing-library/react' import { vi } from 'vitest' import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features' -import { useAppContext } from '@/context/app-context' import { useWorkspacePermissions } from '@/service/use-workspace' import InviteButton from '../invite-button' -vi.mock('@/context/app-context') +const mockUseAppContext = vi.hoisted(() => vi.fn()) + +vi.mock('@/context/app-context', () => ({ + useAppContext: mockUseAppContext, +})) vi.mock('@/context/app-context-state', async (importOriginal) => { const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateAtomMock(importOriginal, () => ({ @@ -41,7 +44,7 @@ describe('InviteButton', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(useAppContext).mockReturnValue({ + mockUseAppContext.mockReturnValue({ currentWorkspace: { id: 'workspace-id' } as ICurrentWorkspace, } as unknown as AppContextValue) }) diff --git a/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/dialog.spec.tsx b/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/dialog.spec.tsx index 96cfd07cd54..f9bcf49335e 100644 --- a/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/dialog.spec.tsx +++ b/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/dialog.spec.tsx @@ -1,7 +1,6 @@ import type { ReactNode } from 'react' import type { AppContextValue } from '@/context/app-context' import { render } from '@testing-library/react' -import { useAppContext } from '@/context/app-context' import EditWorkspaceModal from '../index' type DialogProps = { @@ -14,6 +13,7 @@ let latestOnOpenChange: DialogProps['onOpenChange'] const mockAppContextState = vi.hoisted(() => ({ current: {} as Partial, })) +const mockUseAppContext = vi.hoisted(() => vi.fn()) vi.mock('@langgenius/dify-ui/dialog', () => ({ Dialog: ({ children, onOpenChange }: DialogProps) => { @@ -30,7 +30,7 @@ vi.mock('@langgenius/dify-ui/dialog', () => ({ })) vi.mock('@/context/app-context', () => ({ - useAppContext: vi.fn(), + useAppContext: mockUseAppContext, })) vi.mock('@/context/app-context-state', async (importOriginal) => { @@ -52,7 +52,7 @@ describe('EditWorkspaceModal dialog lifecycle', () => { isCurrentWorkspaceOwner: true, } as never mockAppContextState.current = appContextValue - vi.mocked(useAppContext).mockReturnValue(appContextValue) + mockUseAppContext.mockReturnValue(appContextValue) }) it('should only call onCancel when the dialog requests closing', () => { diff --git a/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx index 5ec4cb538d7..ff2457752bf 100644 --- a/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/edit-workspace-modal/__tests__/index.spec.tsx @@ -3,7 +3,6 @@ import type { ICurrentWorkspace } from '@/models/common' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { vi } from 'vitest' -import { useAppContext } from '@/context/app-context' import { updateWorkspaceInfo } from '@/service/common' import EditWorkspaceModal from '../index' @@ -13,10 +12,13 @@ const toastMocks = vi.hoisted(() => ({ const mockAppContextState = vi.hoisted(() => ({ current: {} as Partial, })) +const mockUseAppContext = vi.hoisted(() => vi.fn()) const getSaveButton = () => screen.getByRole('button', { name: /operation\.(save|saving)/i }) -vi.mock('@/context/app-context') +vi.mock('@/context/app-context', () => ({ + useAppContext: mockUseAppContext, +})) vi.mock('@/context/app-context-state', async (importOriginal) => { const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current) @@ -50,7 +52,7 @@ describe('EditWorkspaceModal', () => { isCurrentWorkspaceOwner: true, } as unknown as AppContextValue mockAppContextState.current = appContextValue - vi.mocked(useAppContext).mockReturnValue(appContextValue) + mockUseAppContext.mockReturnValue(appContextValue) }) afterEach(() => { @@ -165,7 +167,7 @@ describe('EditWorkspaceModal', () => { }) it('should disable confirm button for non-owners', async () => { - vi.mocked(useAppContext).mockReturnValue({ + mockUseAppContext.mockReturnValue({ currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace, isCurrentWorkspaceOwner: false, } as unknown as AppContextValue) diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx index db02b773cc7..855148af034 100644 --- a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/__tests__/index.spec.tsx @@ -3,7 +3,6 @@ import type { ICurrentWorkspace } from '@/models/common' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { vi } from 'vitest' -import { useAppContext } from '@/context/app-context' import { ownershipTransfer, sendOwnerEmail, verifyOwnerEmail } from '@/service/common' import { useMembers } from '@/service/use-common' import TransferOwnershipModal from '../index' @@ -14,8 +13,11 @@ const toastMocks = vi.hoisted(() => ({ const mockAppContextState = vi.hoisted(() => ({ current: {} as Partial, })) +const mockUseAppContext = vi.hoisted(() => vi.fn()) -vi.mock('@/context/app-context') +vi.mock('@/context/app-context', () => ({ + useAppContext: mockUseAppContext, +})) vi.mock('@/context/app-context-state', async (importOriginal) => { const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current) @@ -56,7 +58,7 @@ describe('TransferOwnershipModal', () => { userProfile: { email: 'owner@example.com', id: 'owner-id' }, } as unknown as AppContextValue mockAppContextState.current = appContextValue - vi.mocked(useAppContext).mockReturnValue(appContextValue) + mockUseAppContext.mockReturnValue(appContextValue) vi.mocked(useMembers).mockReturnValue({ data: { accounts: [] }, diff --git a/web/app/components/header/env-nav/__tests__/index.spec.tsx b/web/app/components/header/env-nav/__tests__/index.spec.tsx index 98832145dc3..1a6e1bfe731 100644 --- a/web/app/components/header/env-nav/__tests__/index.spec.tsx +++ b/web/app/components/header/env-nav/__tests__/index.spec.tsx @@ -1,15 +1,15 @@ import type { AppContextValue } from '@/context/app-context' import { render, screen } from '@testing-library/react' import { vi } from 'vitest' -import { useAppContext } from '@/context/app-context' import EnvNav from '../index' const mockAppContextState = vi.hoisted(() => ({ current: {} as Partial, })) +const mockUseAppContext = vi.hoisted(() => vi.fn()) vi.mock('@/context/app-context', () => ({ - useAppContext: vi.fn(), + useAppContext: mockUseAppContext, })) vi.mock('@/context/app-context-state', async (importOriginal) => { @@ -23,8 +23,6 @@ vi.mock('jotai', async (importOriginal) => { }) describe('EnvNav', () => { - const mockUseAppContext = vi.mocked(useAppContext) - beforeEach(() => { vi.clearAllMocks() }) diff --git a/web/app/components/rag-pipeline/components/conversion.tsx b/web/app/components/rag-pipeline/components/conversion.tsx index 48f8fec4c1e..e4a1cc30d28 100644 --- a/web/app/components/rag-pipeline/components/conversion.tsx +++ b/web/app/components/rag-pipeline/components/conversion.tsx @@ -9,10 +9,11 @@ import { } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' import { toast } from '@langgenius/dify-ui/toast' +import { useAtomValue } from 'jotai' import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { userProfileIdAtom, workspacePermissionKeysAtom } from '@/context/app-context-state' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useParams } from '@/next/navigation' import { datasetDetailQueryKeyPrefix } from '@/service/knowledge/use-dataset' @@ -25,8 +26,8 @@ const Conversion = () => { const { t } = useTranslation() const { datasetId } = useParams() const dataset = useDatasetDetailContextWithSelector(state => state.dataset) - const currentUserId = useAppContextWithSelector(state => state.userProfile?.id) - const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const currentUserId = useAtomValue(userProfileIdAtom) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const [showConfirmModal, setShowConfirmModal] = useState(false) const { mutateAsync: convert, isPending } = useConvertDatasetToPipeline() const invalidDatasetDetail = useInvalid([...datasetDetailQueryKeyPrefix, datasetId]) diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx index 72ea0e06f4a..73230ec54b8 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/__tests__/index.spec.tsx @@ -157,6 +157,24 @@ vi.mock('@/context/app-context', () => ({ }), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateAtomMock(importOriginal, () => ({ + userProfile: { + id: mockCurrentUserId, + }, + isLoadingWorkspacePermissionKeys: mockIsLoadingWorkspacePermissionKeys, + workspacePermissionKeys: mockWorkspacePermissionKeys, + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateJotaiMock(importOriginal) +}) + const mockSetShowPricingModal = vi.fn() vi.mock('@/context/modal-context', () => ({ useModalContextSelector: () => mockSetShowPricingModal, diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx index 9625edc7e57..e1fda0a0648 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/index.spec.tsx @@ -149,6 +149,24 @@ vi.mock('@/context/app-context', () => ({ }), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateAtomMock(importOriginal, () => ({ + userProfile: { + id: mockCurrentUserId, + }, + isLoadingWorkspacePermissionKeys: mockIsLoadingWorkspacePermissionKeys, + workspacePermissionKeys: mockWorkspacePermissionKeys, + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateJotaiMock(importOriginal) +}) + const mockSetShowPricingModal = vi.fn() vi.mock('@/context/modal-context', () => ({ useModalContextSelector: (selector: (state: { setShowPricingModal: typeof mockSetShowPricingModal }) => T): T => diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx index 8ba56ad0af0..75353bb6598 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx @@ -137,6 +137,7 @@ vi.mock('@/app/components/base/premium-badge', () => ({ vi.mock('@/config', () => ({ IS_CLOUD_EDITION: true, + MARKETPLACE_API_PREFIX: '/marketplace/api', })) vi.mock('@/app/components/workflow/hooks', () => ({ @@ -165,6 +166,24 @@ vi.mock('@/context/app-context', () => ({ }), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateAtomMock(importOriginal, () => ({ + userProfile: { + id: mockCurrentUserId, + }, + isLoadingWorkspacePermissionKeys: mockIsLoadingWorkspacePermissionKeys, + workspacePermissionKeys: mockWorkspacePermissionKeys, + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/context/i18n', () => ({ useDocLink: () => () => 'https://docs.dify.ai', })) diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx index 1baad279036..d2ed646c505 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx @@ -15,6 +15,7 @@ import { toast } from '@langgenius/dify-ui/toast' import { RiArrowRightUpLine, RiPlayCircleLine, RiTerminalBoxLine } from '@remixicon/react' import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys' import { useBoolean } from 'ahooks' +import { useAtomValue } from 'jotai' import { memo, useCallback, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import { trackEvent } from '@/app/components/base/amplitude' @@ -24,7 +25,7 @@ import PremiumBadge from '@/app/components/base/premium-badge' import { useChecklistBeforePublish } from '@/app/components/workflow/hooks' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { IS_CLOUD_EDITION } from '@/config' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { userProfileIdAtom, workspacePermissionKeysAtom, workspacePermissionKeysLoadingAtom } from '@/context/app-context-state' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useModalContextSelector } from '@/context/modal-context' import { useProviderContextSelector } from '@/context/provider-context' @@ -64,9 +65,9 @@ const Popup = ({ const pipelineId = useStore(s => s.pipelineId) const dataset = useDatasetDetailContextWithSelector(s => s.dataset) const mutateDatasetRes = useDatasetDetailContextWithSelector(s => s.mutateDatasetRes) - const currentUserId = useAppContextWithSelector(state => state.userProfile?.id) - const isLoadingWorkspacePermissionKeys = useAppContextWithSelector(state => state.isLoadingWorkspacePermissionKeys) - const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const currentUserId = useAtomValue(userProfileIdAtom) + const isLoadingWorkspacePermissionKeys = useAtomValue(workspacePermissionKeysLoadingAtom) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const [published, setPublished] = useState(false) const { formatTimeFromNow } = useFormatTimeFromNow() const { handleCheckBeforePublish } = useChecklistBeforePublish() diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-main.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-main.tsx index e8b05c576e9..e5e337d2bc2 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-main.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-main.tsx @@ -1,4 +1,6 @@ import type { WorkflowProps } from '@/app/components/workflow' +import type { Shape as HooksStoreShape } from '@/app/components/workflow/hooks-store' +import { useAtomValue } from 'jotai' import { useCallback, useMemo, @@ -6,7 +8,7 @@ import { import { WorkflowWithInnerContext } from '@/app/components/workflow' import { useSetWorkflowVarsWithValue } from '@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars' import { useWorkflowStore } from '@/app/components/workflow/store' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { userProfileIdAtom, workspacePermissionKeysAtom } from '@/context/app-context-state' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { getDatasetACLCapabilities } from '@/utils/permission' import { @@ -30,8 +32,8 @@ const RagPipelineMain = ({ }: RagPipelineMainProps) => { const workflowStore = useWorkflowStore() const dataset = useDatasetDetailContextWithSelector(s => s.dataset) - const currentUserId = useAppContextWithSelector(state => state.userProfile?.id) - const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const currentUserId = useAtomValue(userProfileIdAtom) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const datasetACLCapabilities = useMemo( () => getDatasetACLCapabilities(dataset?.permission_keys, { currentUserId, @@ -41,7 +43,12 @@ const RagPipelineMain = ({ [dataset?.maintainer, dataset?.permission_keys, currentUserId, workspacePermissionKeys], ) - const handleWorkflowDataUpdate = useCallback((payload: any) => { + type WorkflowDataUpdatePayload = { + rag_pipeline_variables?: Parameters['setRagPipelineVariables']>>[0] + environment_variables?: Parameters['setEnvironmentVariables']>[0] + } + + const handleWorkflowDataUpdate = useCallback((payload: WorkflowDataUpdatePayload) => { const { rag_pipeline_variables, environment_variables, @@ -179,7 +186,7 @@ const RagPipelineMain = ({ nodes={nodes} edges={edges} viewport={viewport} - hooksStore={hooksStore as any} + hooksStore={hooksStore as unknown as Partial} onWorkflowDataUpdate={handleWorkflowDataUpdate} > diff --git a/web/app/components/snippet-list/components/__tests__/snippet-card.spec.tsx b/web/app/components/snippet-list/components/__tests__/snippet-card.spec.tsx index 61e28d96bf7..484faf1e13c 100644 --- a/web/app/components/snippet-list/components/__tests__/snippet-card.spec.tsx +++ b/web/app/components/snippet-list/components/__tests__/snippet-card.spec.tsx @@ -40,6 +40,21 @@ vi.mock('@/context/app-context', () => ({ }), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateAtomMock(importOriginal, () => ({ + isCurrentWorkspaceEditor: mockIsCurrentWorkspaceEditor(), + workspacePermissionKeys: mockWorkspacePermissionKeys(), + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/service/use-common', () => ({ useMembers: () => ({ data: { diff --git a/web/app/components/snippet-list/components/__tests__/snippet-create-button.spec.tsx b/web/app/components/snippet-list/components/__tests__/snippet-create-button.spec.tsx index 8ab329a9b17..f1a717a7c4f 100644 --- a/web/app/components/snippet-list/components/__tests__/snippet-create-button.spec.tsx +++ b/web/app/components/snippet-list/components/__tests__/snippet-create-button.spec.tsx @@ -34,6 +34,20 @@ vi.mock('@/context/app-context', () => ({ }), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateAtomMock(importOriginal, () => ({ + workspacePermissionKeys: mockWorkspacePermissionKeys(), + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/service/use-snippets', () => ({ useCreateSnippetMutation: () => ({ mutateAsync: mockCreateMutateAsync, diff --git a/web/app/components/snippet-list/components/snippet-card.tsx b/web/app/components/snippet-list/components/snippet-card.tsx index 463b99b42e2..285f8d20637 100644 --- a/web/app/components/snippet-list/components/snippet-card.tsx +++ b/web/app/components/snippet-list/components/snippet-card.tsx @@ -19,11 +19,12 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { toast } from '@langgenius/dify-ui/toast' +import { useAtomValue } from 'jotai' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog' import { canCreateAndModifySnippets, canManageSnippets } from '@/app/components/snippets/utils/permission' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { workspacePermissionKeysAtom } from '@/context/app-context-state' import { TagSelector } from '@/features/tag-management/components/tag-selector' import Link from '@/next/link' import { useMembers } from '@/service/use-common' @@ -46,7 +47,7 @@ const SnippetCard = ({ }: Props) => { const { t } = useTranslation('snippet') const { t: tCommon } = useTranslation() - const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const { data: membersData } = useMembers() const [isOperationsMenuOpen, setIsOperationsMenuOpen] = useState(false) const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) diff --git a/web/app/components/snippet-list/index.tsx b/web/app/components/snippet-list/index.tsx index fadf48b86ef..34a01b518da 100644 --- a/web/app/components/snippet-list/index.tsx +++ b/web/app/components/snippet-list/index.tsx @@ -5,9 +5,10 @@ import type { SnippetListItem } from '@/types/snippet' import { cn } from '@langgenius/dify-ui/cn' import { Input } from '@langgenius/dify-ui/input' import { useDebounce } from 'ahooks' +import { useAtomValue } from 'jotai' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { currentWorkspaceLoadingAtom, workspacePermissionKeysAtom } from '@/context/app-context-state' import { TagFilter } from '@/features/tag-management/components/tag-filter' import useDocumentTitle from '@/hooks/use-document-title' import dynamic from '@/next/dynamic' @@ -56,8 +57,8 @@ const SnippetCardSkeleton = ({ count }: SnippetCardSkeletonProps) => { const SnippetList = () => { const { t } = useTranslation() - const isLoadingCurrentWorkspace = useAppContextWithSelector(state => state.isLoadingCurrentWorkspace) - const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const isLoadingCurrentWorkspace = useAtomValue(currentWorkspaceLoadingAtom) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) // eslint-disable-next-line react/use-state -- custom URL query hook, not React.useState const { query: { tagIDs, keywords, creatorIDs }, diff --git a/web/app/components/snippets/__tests__/import-snippet-dsl-dialog.spec.tsx b/web/app/components/snippets/__tests__/import-snippet-dsl-dialog.spec.tsx index 90517f9d419..ccb1df06f82 100644 --- a/web/app/components/snippets/__tests__/import-snippet-dsl-dialog.spec.tsx +++ b/web/app/components/snippets/__tests__/import-snippet-dsl-dialog.spec.tsx @@ -33,6 +33,20 @@ vi.mock('@/context/app-context', () => ({ }), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateAtomMock(importOriginal, () => ({ + workspacePermissionKeys: contextMocks.workspacePermissionKeys, + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@langgenius/dify-ui/toast', () => ({ toast: toastMocks, })) diff --git a/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx b/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx index 5d79147507a..9b1b289531b 100644 --- a/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx +++ b/web/app/components/snippets/components/__tests__/snippet-main.spec.tsx @@ -58,6 +58,20 @@ vi.mock('@/context/app-context', () => ({ }), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateAtomMock(importOriginal, () => ({ + workspacePermissionKeys: mockAppContext.workspacePermissionKeys, + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateJotaiMock(importOriginal) +}) + let capturedHooksStore: Record | undefined let capturedWorkflowNodes: WorkflowProps['nodes'] | undefined let snippetDetailStoreState: { diff --git a/web/app/components/snippets/components/snippet-main.tsx b/web/app/components/snippets/components/snippet-main.tsx index c712d3b35cd..55b860bb50b 100644 --- a/web/app/components/snippets/components/snippet-main.tsx +++ b/web/app/components/snippets/components/snippet-main.tsx @@ -5,6 +5,7 @@ import type { Shape as HooksStoreShape } from '@/app/components/workflow/hooks-s import type { SnippetCanvasData, SnippetDetailPayload, SnippetInputField } from '@/models/snippet' import type { SnippetDraftSyncPayload } from '@/types/snippet' import { toast } from '@langgenius/dify-ui/toast' +import { useAtomValue } from 'jotai' import { useCallback, useEffect, @@ -23,7 +24,7 @@ import { initialEdges, initialNodes, } from '@/app/components/workflow/utils' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { workspacePermissionKeysAtom } from '@/context/app-context-state' import { useSnippetDraftStore } from '../draft-store' import { useConfigsMap } from '../hooks/use-configs-map' import { useGetRunAndTraceUrl } from '../hooks/use-get-run-and-trace-url' @@ -143,7 +144,7 @@ const SnippetMain = ({ const effectiveDraftEdges = localDraftState?.edges ?? draftEdges const effectiveDraftViewport = localDraftState?.viewport ?? draftViewport const { graph, snippet } = effectiveDraftPayload - const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canEditSnippet = canCreateAndModifySnippets(workspacePermissionKeys) const canSave = canEditSnippet && currentCanvasNodeCount > 0 const { diff --git a/web/app/components/snippets/hooks/__tests__/use-create-snippet.spec.tsx b/web/app/components/snippets/hooks/__tests__/use-create-snippet.spec.tsx index a6cf99bcba0..4a80ccf7e6e 100644 --- a/web/app/components/snippets/hooks/__tests__/use-create-snippet.spec.tsx +++ b/web/app/components/snippets/hooks/__tests__/use-create-snippet.spec.tsx @@ -52,6 +52,20 @@ vi.mock('@/context/app-context', () => ({ }), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateAtomMock(importOriginal, () => ({ + workspacePermissionKeys: mockWorkspacePermissionKeys(), + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { success: (...args: unknown[]) => mockToastSuccess(...args), diff --git a/web/app/components/snippets/hooks/use-create-snippet.ts b/web/app/components/snippets/hooks/use-create-snippet.ts index 69af925cee5..6dd43379d33 100644 --- a/web/app/components/snippets/hooks/use-create-snippet.ts +++ b/web/app/components/snippets/hooks/use-create-snippet.ts @@ -1,8 +1,9 @@ import type { CreateSnippetDialogPayload } from '@/app/components/snippets/create-snippet-dialog' import { toast } from '@langgenius/dify-ui/toast' +import { useAtomValue } from 'jotai' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { workspacePermissionKeysAtom } from '@/context/app-context-state' import { useRouter } from '@/next/navigation' import { consoleClient } from '@/service/client' import { useCreateSnippetMutation } from '@/service/use-snippets' @@ -11,7 +12,7 @@ import { canCreateAndModifySnippets } from '../utils/permission' export const useCreateSnippet = () => { const { t } = useTranslation() const { push } = useRouter() - const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const createSnippetMutation = useCreateSnippetMutation() const [isCreateSnippetDialogOpen, setIsCreateSnippetDialogOpen] = useState(false) const [isCreatingSnippet, setIsCreatingSnippet] = useState(false) diff --git a/web/app/components/snippets/import-snippet-dsl-dialog.tsx b/web/app/components/snippets/import-snippet-dsl-dialog.tsx index 7e99726102f..4c1dcfa65d3 100644 --- a/web/app/components/snippets/import-snippet-dsl-dialog.tsx +++ b/web/app/components/snippets/import-snippet-dsl-dialog.tsx @@ -16,10 +16,11 @@ import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { Input } from '@langgenius/dify-ui/input' import { toast } from '@langgenius/dify-ui/toast' +import { useAtomValue } from 'jotai' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Uploader from '@/app/components/app/create-from-dsl-modal/uploader' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { workspacePermissionKeysAtom } from '@/context/app-context-state' import { useRouter } from '@/next/navigation' import { useConfirmSnippetImportMutation, @@ -107,7 +108,7 @@ function ImportSnippetDSLDialog({ }: ImportSnippetDSLDialogProps) { const { t } = useTranslation() const { push } = useRouter() - const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canCreateAndModifySnippet = canCreateAndModifySnippets(workspacePermissionKeys) const importSnippetMutation = useImportSnippetDSLMutation() const confirmSnippetImportMutation = useConfirmSnippetImportMutation() diff --git a/web/app/components/workflow-app/__tests__/index.spec.tsx b/web/app/components/workflow-app/__tests__/index.spec.tsx index 28810aa264f..8c52460c485 100644 --- a/web/app/components/workflow-app/__tests__/index.spec.tsx +++ b/web/app/components/workflow-app/__tests__/index.spec.tsx @@ -88,6 +88,23 @@ vi.mock('@/context/app-context', () => ({ useAppContext: () => appContextState, })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateAtomMock(importOriginal, () => ({ + isLoadingCurrentWorkspace: appContextState.isLoadingCurrentWorkspace, + currentWorkspace: appContextState.currentWorkspace, + userProfile: appContextState.userProfile, + workspacePermissionKeys: appContextState.workspacePermissionKeys, + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/next/navigation', () => ({ useSearchParams: () => ({ get: (key: string) => (key === 'replayRunId' ? searchParamsValue : null), diff --git a/web/app/components/workflow-app/components/workflow-main.tsx b/web/app/components/workflow-app/components/workflow-main.tsx index bfd0c3aa3d4..a16b6c831a5 100644 --- a/web/app/components/workflow-app/components/workflow-main.tsx +++ b/web/app/components/workflow-app/components/workflow-main.tsx @@ -4,6 +4,7 @@ import type { CollaborationUpdate } from '@/app/components/workflow/collaboratio import type { Shape as HooksStoreShape } from '@/app/components/workflow/hooks-store/store' import type { Edge, Node } from '@/app/components/workflow/types' import type { FetchWorkflowDraftResponse } from '@/types/workflow' +import { useAtomValue } from 'jotai' import { useCallback, useEffect, @@ -21,7 +22,7 @@ import { useCollaboration } from '@/app/components/workflow/collaboration/hooks/ import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-interactions' import { useStore, useWorkflowStore } from '@/app/components/workflow/store' import { SupportUploadFileTypes } from '@/app/components/workflow/types' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { userProfileIdAtom, workspacePermissionKeysAtom } from '@/context/app-context-state' import { fetchWorkflowDraft } from '@/service/workflow' import { getAppACLCapabilities } from '@/utils/permission' import { @@ -77,8 +78,8 @@ const WorkflowMain = ({ const filteredCursors = Object.fromEntries( Object.entries(cursors).filter(([userId]) => userId !== myUserId), ) - const currentUserId = useAppContextWithSelector(state => state.userProfile?.id) - const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const currentUserId = useAtomValue(userProfileIdAtom) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const appACLCapabilities = useMemo( () => getAppACLCapabilities(appDetail?.permission_keys, { currentUserId, diff --git a/web/app/components/workflow-app/hooks/__tests__/use-workflow-init.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-workflow-init.spec.ts index 4bc3c1a8fcb..925dcf4ca88 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-workflow-init.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-workflow-init.spec.ts @@ -54,6 +54,21 @@ vi.mock('@/context/app-context', () => ({ }), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateAtomMock(importOriginal, () => ({ + userProfile: { id: 'user-1' }, + workspacePermissionKeys: ['app.create_and_management'], + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('../use-workflow-template', () => ({ useWorkflowTemplate: () => ({ nodes: appStoreState.appDetail.mode === 'workflow' diff --git a/web/app/components/workflow-app/hooks/use-workflow-init.ts b/web/app/components/workflow-app/hooks/use-workflow-init.ts index 2845b97f46d..782a8970699 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-init.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-init.ts @@ -1,6 +1,7 @@ import type { Edge, Node } from '@/app/components/workflow/types' import type { FileUploadConfigResponse } from '@/models/common' import type { FetchWorkflowDraftResponse } from '@/types/workflow' +import { useAtomValue } from 'jotai' import { useCallback, useEffect, @@ -13,7 +14,7 @@ import { useWorkflowStore, } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { userProfileIdAtom, workspacePermissionKeysAtom } from '@/context/app-context-state' import { useWorkflowConfig } from '@/service/use-workflow' import { fetchNodesDefaultConfigs, @@ -71,8 +72,8 @@ export const useWorkflowInit = () => { edges: edgesTemplate, } = useWorkflowTemplate() const appDetail = useAppStore(state => state.appDetail)! - const currentUserId = useAppContextWithSelector(state => state.userProfile?.id) - const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const currentUserId = useAtomValue(userProfileIdAtom) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const appACLCapabilities = useMemo(() => getAppACLCapabilities(appDetail.permission_keys, { currentUserId, resourceMaintainer: appDetail.maintainer, @@ -118,9 +119,10 @@ export const useWorkflowInit = () => { setSyncWorkflowDraftHash(initialData.hash) setIsLoading(false) } - catch (error: any) { - if (error && error.json && !error.bodyUsed && appDetail) { - error.json().then((err: any) => { + catch (error: unknown) { + const responseError = error as { bodyUsed?: boolean, json?: () => Promise<{ code?: string }> } + if (responseError.json && !responseError.bodyUsed && appDetail) { + responseError.json().then((err) => { if (err.code === 'draft_workflow_not_exist') { const isAdvancedChat = appDetail.mode === AppModeEnum.ADVANCED_CHAT const initialGraph = { @@ -187,7 +189,7 @@ export const useWorkflowInit = () => { if (!acc[block.type]) acc[block.type] = { ...block.config } return acc - }, {} as Record), + }, {} as Record), }) workflowStore.getState().setPublishedAt(publishedWorkflow?.created_at ?? 0) const graph = publishedWorkflow?.graph diff --git a/web/app/components/workflow-app/index.tsx b/web/app/components/workflow-app/index.tsx index dd572c0b7e5..ae0f90abf95 100644 --- a/web/app/components/workflow-app/index.tsx +++ b/web/app/components/workflow-app/index.tsx @@ -2,6 +2,7 @@ import type { Features as FeaturesData } from '@/app/components/base/features/types' import type { InjectWorkflowStoreSliceFn } from '@/app/components/workflow/store' +import { useAtomValue } from 'jotai' import { useEffect, useMemo, @@ -19,7 +20,7 @@ import { initialEdges, initialNodes, } from '@/app/components/workflow/utils' -import { useAppContext } from '@/context/app-context' +import { currentWorkspaceAtom, currentWorkspaceLoadingAtom, userProfileIdAtom, workspacePermissionKeysAtom } from '@/context/app-context-state' import { useSearchParams } from '@/next/navigation' import { fetchRunDetail } from '@/service/log' import { useAppTriggers } from '@/service/use-tools' @@ -45,16 +46,19 @@ const WorkflowAppWithAdditionalContext = () => { fileUploadConfigResponse, } = useWorkflowInit() const workflowStore = useWorkflowStore() - const { isLoadingCurrentWorkspace, currentWorkspace, userProfile, workspacePermissionKeys } = useAppContext() + const isLoadingCurrentWorkspace = useAtomValue(currentWorkspaceLoadingAtom) + const currentWorkspace = useAtomValue(currentWorkspaceAtom) + const currentUserId = useAtomValue(userProfileIdAtom) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) // Initialize trigger status at application level const { setTriggerStatuses } = useTriggerStatusStore() const appDetail = useAppStore(s => s.appDetail) const appACLCapabilities = useMemo(() => getAppACLCapabilities(appDetail?.permission_keys, { - currentUserId: userProfile?.id, + currentUserId, resourceMaintainer: appDetail?.maintainer, workspacePermissionKeys, - }), [appDetail?.maintainer, appDetail?.permission_keys, userProfile?.id, workspacePermissionKeys]) + }), [appDetail?.maintainer, appDetail?.permission_keys, currentUserId, workspacePermissionKeys]) const appId = appDetail?.id const isWorkflowMode = appDetail?.mode === AppModeEnum.WORKFLOW const { data: triggersResponse } = useAppTriggers(isWorkflowMode ? appId : undefined, { @@ -78,8 +82,8 @@ const WorkflowAppWithAdditionalContext = () => { // Cancel any pending debounced sync operations const { debouncedSyncWorkflowDraft } = workflowStore.getState() // The debounced function from lodash has a cancel method - if (debouncedSyncWorkflowDraft && 'cancel' in debouncedSyncWorkflowDraft) - (debouncedSyncWorkflowDraft as any).cancel() + const cancellableSyncWorkflowDraft = debouncedSyncWorkflowDraft as { cancel?: () => void } | undefined + cancellableSyncWorkflowDraft?.cancel?.() } }, [workflowStore]) diff --git a/web/app/components/workflow/nodes/trigger-schedule/__tests__/use-config.spec.ts b/web/app/components/workflow/nodes/trigger-schedule/__tests__/use-config.spec.ts index c334f0e9525..bc445f4211d 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/__tests__/use-config.spec.ts +++ b/web/app/components/workflow/nodes/trigger-schedule/__tests__/use-config.spec.ts @@ -2,11 +2,12 @@ import type { ScheduleTriggerNodeType } from '../types' import { renderHook } from '@testing-library/react' import { useNodesReadOnly } from '@/app/components/workflow/hooks' import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud' -import { useAppContext } from '@/context/app-context' import { createAccountProfileQueryWrapper } from '@/test/account-profile-query' import { BlockEnum } from '../../../types' import useConfig from '../use-config' +const mockUseAppContext = vi.hoisted(() => vi.fn()) + vi.mock('@/app/components/workflow/hooks', () => ({ useNodesReadOnly: vi.fn(), })) @@ -17,12 +18,11 @@ vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({ })) vi.mock('@/context/app-context', () => ({ - useAppContext: vi.fn(), + useAppContext: mockUseAppContext, })) const mockUseNodesReadOnly = vi.mocked(useNodesReadOnly) const mockUseNodeCrud = vi.mocked(useNodeCrud) -const mockUseAppContext = vi.mocked(useAppContext) const setInputs = vi.fn() @@ -48,7 +48,7 @@ describe('trigger-schedule/use-config', () => { mockUseNodesReadOnly.mockReturnValue({ nodesReadOnly: false, getNodesReadOnly: () => false }) mockUseAppContext.mockReturnValue({ userProfile: { timezone: 'Asia/Shanghai' }, - } as ReturnType) + }) mockUseNodeCrud.mockReturnValue({ inputs: createData(), setInputs, diff --git a/web/app/education-apply/education-apply-page.tsx b/web/app/education-apply/education-apply-page.tsx index 6bc23351525..7ab654d0b63 100644 --- a/web/app/education-apply/education-apply-page.tsx +++ b/web/app/education-apply/education-apply-page.tsx @@ -8,12 +8,13 @@ import { Checkbox } from '@langgenius/dify-ui/checkbox' import { toast } from '@langgenius/dify-ui/toast' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { noop } from 'es-toolkit/function' +import { useAtomValue } from 'jotai' import { useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import { useEducationDiscount } from '@/app/components/billing/hooks/use-education-discount' import { Plan } from '@/app/components/billing/type' import { useSetEducationVerifying } from '@/app/education-apply/storage' -import { useAppContext } from '@/context/app-context' +import { currentWorkspaceAtom, workspacePermissionKeysAtom } from '@/context/app-context-state' import { useDocLink } from '@/context/i18n' import { useProviderContext } from '@/context/provider-context' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' @@ -52,7 +53,8 @@ const EducationApplyAgeContent = () => { mutateAsync: educationAdd, } = useEducationAdd({ onSuccess: noop }) const { onPlanInfoChanged, isEducationAccount, plan } = useProviderContext() - const { currentWorkspace, workspacePermissionKeys } = useAppContext() + const currentWorkspace = useAtomValue(currentWorkspaceAtom) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const updateEducationStatus = useInvalidateEducationStatus() const docLink = useDocLink() const { handleEducationDiscount } = useEducationDiscount() diff --git a/web/app/education-apply/user-info.tsx b/web/app/education-apply/user-info.tsx index b25d693a657..2abd17eb238 100644 --- a/web/app/education-apply/user-info.tsx +++ b/web/app/education-apply/user-info.tsx @@ -1,15 +1,16 @@ import { Avatar } from '@langgenius/dify-ui/avatar' import { Button } from '@langgenius/dify-ui/button' +import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' import { Triangle } from '@/app/components/base/icons/src/public/education' -import { useAppContext } from '@/context/app-context' +import { userProfileAtom } from '@/context/app-context-state' import { useRouter } from '@/next/navigation' import { useLogout } from '@/service/use-common' const UserInfo = () => { const router = useRouter() const { t } = useTranslation() - const { userProfile } = useAppContext() + const userProfile = useAtomValue(userProfileAtom) const { mutateAsync: logout } = useLogout() const handleLogout = async () => { diff --git a/web/context/__tests__/app-context-provider.spec.tsx b/web/context/__tests__/app-context-provider.spec.tsx index 7b574823025..54bd312a03a 100644 --- a/web/context/__tests__/app-context-provider.spec.tsx +++ b/web/context/__tests__/app-context-provider.spec.tsx @@ -5,11 +5,12 @@ import { Provider as JotaiProvider } from 'jotai' import { queryClientAtom } from 'jotai-tanstack-query' import { useHydrateAtoms } from 'jotai/react/utils' import { Suspense } from 'react' +import { useContext } from 'use-context-selector' import { setUserId, setUserProperties } from '@/app/components/base/amplitude' import { flushRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking' import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils' import { ZENDESK_FIELD_IDS } from '@/config' -import { initialWorkspaceInfo, useAppContext, useSelector } from '../app-context' +import { AppContext, initialWorkspaceInfo, useSelector } from '../app-context' import { AppContextProvider } from '../app-context-provider' const mockGetRequest = vi.hoisted(() => vi.fn()) @@ -157,7 +158,7 @@ vi.mock('@/app/components/header/maintenance-notice', () => ({ })) function AppContextProbe() { - const context = useAppContext() + const context = useContext(AppContext) const selectedWorkspacePermissionKeys = useSelector(state => state.workspacePermissionKeys) return ( diff --git a/web/context/app-context.ts b/web/context/app-context.ts index e89546a1e1d..7606542170b 100644 --- a/web/context/app-context.ts +++ b/web/context/app-context.ts @@ -3,7 +3,7 @@ import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen' import type { ICurrentWorkspace, LangGeniusVersionResponse } from '@/models/common' import { noop } from 'es-toolkit/function' -import { createContext, useContext, useContextSelector } from 'use-context-selector' +import { createContext, useContextSelector } from 'use-context-selector' import { initialLangGeniusVersionInfo as defaultLangGeniusVersionInfo, userProfilePlaceholder as defaultUserProfilePlaceholder, @@ -53,5 +53,3 @@ export const AppContext = createContext({ export function useSelector(selector: (value: AppContextValue) => T): T { return useContextSelector(AppContext, selector) } - -export const useAppContext = () => useContext(AppContext) diff --git a/web/context/modal-context-provider.tsx b/web/context/modal-context-provider.tsx index 2c2d9ad185d..910c6856208 100644 --- a/web/context/modal-context-provider.tsx +++ b/web/context/modal-context-provider.tsx @@ -11,6 +11,7 @@ import type { InputVar } from '@/app/components/workflow/types' import type { ExpireNoticeModalPayloadProps } from '@/app/education-apply/expire-notice-modal' import type { ExternalDataTool } from '@/models/common' import type { ModerationConfig, PromptVariable } from '@/models/debug' +import { useAtomValue } from 'jotai' import { useCallback, useEffect, useRef, useState } from 'react' import { DEFAULT_ACCOUNT_SETTING_TAB, @@ -20,7 +21,7 @@ import { isWorkspaceSettingTab, } from '@/app/components/header/account-setting/constants' import { useSetEducationVerifying } from '@/app/education-apply/storage' -import { useAppContext } from '@/context/app-context' +import { currentWorkspaceIdAtom } from '@/context/app-context-state' import { useProviderContext } from '@/context/provider-context' import { useAccountSettingModal, @@ -103,7 +104,7 @@ export const ModalContextProvider = ({ }> | null>(null) const [showUpdatePluginModal, setShowUpdatePluginModal] = useState | null>(null) const [showEducationExpireNoticeModal, setShowEducationExpireNoticeModal] = useState | null>(null) - const { currentWorkspace } = useAppContext() + const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom) const setEducationVerifying = useSetEducationVerifying() const [showAnnotationFullModal, setShowAnnotationFullModal] = useState(false) @@ -146,7 +147,7 @@ export const ModalContextProvider = ({ } = useTriggerEventsLimitModal({ plan, isFetchedPlan, - currentWorkspaceId: currentWorkspace?.id, + currentWorkspaceId, }) const handleCancelModerationSettingModal = () => { diff --git a/web/context/modal-context.test.tsx b/web/context/modal-context.test.tsx index 3b972156f86..3ff7480a6d5 100644 --- a/web/context/modal-context.test.tsx +++ b/web/context/modal-context.test.tsx @@ -52,6 +52,18 @@ vi.mock('@/context/app-context', () => ({ useAppContext: () => mockUseAppContext(), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateAtomMock(importOriginal, () => mockUseAppContext()) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateJotaiMock(importOriginal) +}) + type DefaultPlanShape = typeof defaultPlan type ResetShape = { apiRateLimit: number | null diff --git a/web/features/deployments/detail/access/permissions/access-subject-selector/subject-options.tsx b/web/features/deployments/detail/access/permissions/access-subject-selector/subject-options.tsx index 1a09df73636..447a8009056 100644 --- a/web/features/deployments/detail/access/permissions/access-subject-selector/subject-options.tsx +++ b/web/features/deployments/detail/access/permissions/access-subject-selector/subject-options.tsx @@ -15,8 +15,9 @@ import { ComboboxItem, ComboboxItemText, } from '@langgenius/dify-ui/combobox' +import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' -import { useSelector } from '@/context/app-context' +import { userProfileAtom } from '@/context/app-context-state' import { SubjectType } from '@/models/access-control' export function SubjectItem({ @@ -154,7 +155,7 @@ type MemberItemProps = { } function MemberItem({ member, subject, selectedMembers }: MemberItemProps) { - const currentUser = useSelector(s => s.userProfile) + const currentUser = useAtomValue(userProfileAtom) const { t } = useTranslation() const isChecked = selectedMembers.some(selectedMember => selectedMember.id === member.id) return ( diff --git a/web/features/tag-management/__tests__/tag-filter.spec.tsx b/web/features/tag-management/__tests__/tag-filter.spec.tsx index 13fd2b8e64d..dd2e31b74e5 100644 --- a/web/features/tag-management/__tests__/tag-filter.spec.tsx +++ b/web/features/tag-management/__tests__/tag-filter.spec.tsx @@ -21,6 +21,20 @@ vi.mock('@/context/app-context', () => ({ }), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateAtomMock(importOriginal, () => ({ + workspacePermissionKeys: mockWorkspacePermissionKeys.value, + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateJotaiMock(importOriginal) +}) + const mockTags: Tag[] = [ { id: 'tag-1', name: 'Frontend', type: 'app', binding_count: '' }, { id: 'tag-2', name: 'Backend', type: 'app', binding_count: '' }, diff --git a/web/features/tag-management/__tests__/tag-management-modal.spec.tsx b/web/features/tag-management/__tests__/tag-management-modal.spec.tsx index c09a6cb9822..a3b91cae337 100644 --- a/web/features/tag-management/__tests__/tag-management-modal.spec.tsx +++ b/web/features/tag-management/__tests__/tag-management-modal.spec.tsx @@ -50,6 +50,20 @@ vi.mock('@/context/app-context', () => ({ }), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateAtomMock(importOriginal, () => ({ + workspacePermissionKeys: mockWorkspacePermissionKeys.value, + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/service/client', () => ({ consoleQuery: { tags: { diff --git a/web/features/tag-management/__tests__/tag-search-content.spec.tsx b/web/features/tag-management/__tests__/tag-search-content.spec.tsx index a97f7283e9d..7b5f482c768 100644 --- a/web/features/tag-management/__tests__/tag-search-content.spec.tsx +++ b/web/features/tag-management/__tests__/tag-search-content.spec.tsx @@ -21,6 +21,20 @@ vi.mock('@/context/app-context', () => ({ }), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateAtomMock(importOriginal, () => ({ + workspacePermissionKeys: mockWorkspacePermissionKeys.value, + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateJotaiMock(importOriginal) +}) + const i18n = { selectorPlaceholder: 'common.tag.selectorPlaceholder', operationClear: 'common.operation.clear', diff --git a/web/features/tag-management/__tests__/tag-selector.spec.tsx b/web/features/tag-management/__tests__/tag-selector.spec.tsx index 754cf6a194c..cabdb86b6ec 100644 --- a/web/features/tag-management/__tests__/tag-selector.spec.tsx +++ b/web/features/tag-management/__tests__/tag-selector.spec.tsx @@ -39,6 +39,20 @@ vi.mock('@/context/app-context', () => ({ }), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateAtomMock(importOriginal, () => ({ + workspacePermissionKeys: mockWorkspacePermissionKeys.value, + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@tanstack/react-query', () => ({ useQuery: () => ({ data: mockUseQueryData.current }), useMutation: (mutationOptions: { mutationFn: (input: unknown) => Promise }) => ({ diff --git a/web/features/tag-management/components/tag-management-modal.tsx b/web/features/tag-management/components/tag-management-modal.tsx index 78d8dbb9d54..1e9dc18a53d 100644 --- a/web/features/tag-management/components/tag-management-modal.tsx +++ b/web/features/tag-management/components/tag-management-modal.tsx @@ -3,9 +3,10 @@ import type { TagType } from '@dify/contracts/api/console/tags/types.gen' import { Dialog, DialogCloseButton, DialogContent } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { useMutation, useQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { workspacePermissionKeysAtom } from '@/context/app-context-state' import { consoleQuery } from '@/service/client' import { hasPermission } from '@/utils/permission' import { getTagManagePermissionKey } from '../utils' @@ -19,7 +20,7 @@ type TagManagementModalProps = { } export const TagManagementModal = ({ show, type, onClose, onTagsChange }: TagManagementModalProps) => { const { t } = useTranslation() - const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canManageTags = hasPermission(workspacePermissionKeys, getTagManagePermissionKey(type)) const { data: tagList = [] } = useQuery(consoleQuery.tags.get.queryOptions({ input: { @@ -69,7 +70,7 @@ export const TagManagementModal = ({ show, type, onClose, onTagsChange }: TagMan
{t('tag.manageTags', { ns: 'common' })}
- setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && !e.nativeEvent.isComposing && createNewTag()} onBlur={createNewTag} /> + setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && !e.nativeEvent.isComposing && createNewTag()} onBlur={createNewTag} /> {tagList.map(tag => ())}
diff --git a/web/features/tag-management/components/tag-search-content.tsx b/web/features/tag-management/components/tag-search-content.tsx index 474fd32c697..9748c17adc8 100644 --- a/web/features/tag-management/components/tag-search-content.tsx +++ b/web/features/tag-management/components/tag-search-content.tsx @@ -1,9 +1,10 @@ import type { TagType } from '@dify/contracts/api/console/tags/types.gen' import type { TagComboboxItem } from './tag-combobox-item' import { ComboboxEmpty, ComboboxInput, ComboboxInputGroup, ComboboxItem, ComboboxItemIndicator, ComboboxItemText, ComboboxList, ComboboxSeparator, useComboboxFilteredItems } from '@langgenius/dify-ui/combobox' +import { useAtomValue } from 'jotai' import { Fragment } from 'react' import { useTranslation } from 'react-i18next' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { workspacePermissionKeysAtom } from '@/context/app-context-state' import { hasPermission } from '@/utils/permission' import { getTagManagePermissionKey } from '../utils' import { isCreateTagOption } from './tag-combobox-item' @@ -26,7 +27,7 @@ export const TagSearchContent = ({ canBindOrUnbindTags = false, }: TagSearchContentProps) => { const { t } = useTranslation() - const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canManageTags = hasPermission(workspacePermissionKeys, getTagManagePermissionKey(type)) const filteredItems = useComboboxFilteredItems() const realItemCount = filteredItems.filter(tag => !isCreateTagOption(tag)).length diff --git a/web/features/tag-management/components/tag-selector.tsx b/web/features/tag-management/components/tag-selector.tsx index 1a1f463b5eb..105b19699e0 100644 --- a/web/features/tag-management/components/tag-selector.tsx +++ b/web/features/tag-management/components/tag-selector.tsx @@ -6,9 +6,10 @@ import { cn } from '@langgenius/dify-ui/cn' import { Combobox, ComboboxContent, ComboboxTrigger } from '@langgenius/dify-ui/combobox' import { toast } from '@langgenius/dify-ui/toast' import { useMutation, useQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { workspacePermissionKeysAtom } from '@/context/app-context-state' import { consoleQuery } from '@/service/client' import { hasPermission } from '@/utils/permission' import { useApplyTagBindingsMutation } from '../hooks/use-tag-mutations' @@ -71,7 +72,7 @@ export const TagSelector = ({ const [open, setOpen] = useState(false) const [draftTags, setDraftTags] = useState(value) const [inputValue, setInputValue] = useState('') - const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canManageTags = hasPermission(workspacePermissionKeys, getTagManagePermissionKey(type)) const applyTagBindingsMutation = useApplyTagBindingsMutation() diff --git a/web/hooks/use-import-dsl.ts b/web/hooks/use-import-dsl.ts index bd6c564b987..8ca6488cb4e 100644 --- a/web/hooks/use-import-dsl.ts +++ b/web/hooks/use-import-dsl.ts @@ -5,6 +5,7 @@ import type { import type { AppIconType } from '@/types/app' import { toast } from '@langgenius/dify-ui/toast' import { useSuspenseQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' import { useCallback, useRef, @@ -13,7 +14,7 @@ import { import { useTranslation } from 'react-i18next' import { useSetNeedRefreshAppList } from '@/app/components/apps/storage' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' -import { useAppContext } from '@/context/app-context' +import { userProfileIdAtom, workspacePermissionKeysAtom } from '@/context/app-context-state' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { DSLImportStatus } from '@/models/app' import { useRouter } from '@/next/navigation' @@ -46,7 +47,8 @@ export const useImportDSL = () => { const { push } = useRouter() const invalidateAppList = useInvalidateAppList() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const { userProfile, workspacePermissionKeys } = useAppContext() + const currentUserId = useAtomValue(userProfileIdAtom) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const isRbacEnabled = systemFeatures.rbac_enabled const [versions, setVersions] = useState<{ importedVersion: string, systemVersion: string }>() const importIdRef = useRef('') @@ -98,8 +100,8 @@ export const useImportDSL = () => { invalidateAppList() await handleCheckPluginDependencies(app_id) getRedirection({ id: app_id, mode: app_mode, permission_keys }, push, { - currentUserId: userProfile?.id, - resourceMaintainer: userProfile?.id, + currentUserId, + resourceMaintainer: currentUserId, workspacePermissionKeys, isRbacEnabled, }) @@ -124,7 +126,7 @@ export const useImportDSL = () => { finally { setIsFetching(false) } - }, [isFetching, t, handleCheckPluginDependencies, isRbacEnabled, push, setNeedRefresh, invalidateAppList, userProfile?.id, workspacePermissionKeys]) + }, [isFetching, t, handleCheckPluginDependencies, isRbacEnabled, push, setNeedRefresh, invalidateAppList, currentUserId, workspacePermissionKeys]) const handleImportDSLConfirm = useCallback(async ( { @@ -154,8 +156,8 @@ export const useImportDSL = () => { setNeedRefresh('1') invalidateAppList() getRedirection({ id: app_id, mode: app_mode, permission_keys }, push, { - currentUserId: userProfile?.id, - resourceMaintainer: userProfile?.id, + currentUserId, + resourceMaintainer: currentUserId, workspacePermissionKeys, isRbacEnabled, }) @@ -172,7 +174,7 @@ export const useImportDSL = () => { finally { setIsFetching(false) } - }, [isFetching, t, handleCheckPluginDependencies, isRbacEnabled, setNeedRefresh, push, invalidateAppList, userProfile?.id, workspacePermissionKeys]) + }, [isFetching, t, handleCheckPluginDependencies, isRbacEnabled, setNeedRefresh, push, invalidateAppList, currentUserId, workspacePermissionKeys]) return { handleImportDSL,