diff --git a/eslint-suppressions.json b/eslint-suppressions.json index e141792779d..34cab985f05 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -279,11 +279,6 @@ "count": 1 } }, - "web/app/account/(commonLayout)/delete-account/components/check-email.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/account/(commonLayout)/delete-account/components/verify-email.tsx": { "no-restricted-imports": { "count": 1 @@ -3401,30 +3396,11 @@ "count": 2 } }, - "web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx": { - "jsx-a11y/no-autofocus": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/header/account-setting/members-page/invite-modal/index.tsx": { "jsx-a11y/no-autofocus": { "count": 1 } }, - "web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx": { - "erasable-syntax-only/enums": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 2 - } - }, "web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 1 @@ -3566,14 +3542,6 @@ "count": 2 } }, - "web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list.tsx": { - "jsx-a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx-a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/components/header/account-setting/model-provider-page/provider-added-card/model-load-balancing-configs.tsx": { "jsx-a11y/click-events-have-key-events": { "count": 2 diff --git a/web/__tests__/header/account-dropdown-flow.test.tsx b/web/__tests__/header/account-dropdown-flow.test.tsx index fd651931b50..c949bb3f99a 100644 --- a/web/__tests__/header/account-dropdown-flow.test.tsx +++ b/web/__tests__/header/account-dropdown-flow.test.tsx @@ -6,11 +6,32 @@ import AccountDropdown from '@/app/components/header/account-dropdown' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' const { + mockAppContextState, mockPush, mockLogout, mockResetUser, mockSetShowAccountSettingModal, } = vi.hoisted(() => ({ + mockAppContextState: { + userProfile: { + id: 'user-1', + name: 'Ada Lovelace', + email: 'ada@example.com', + avatar: '', + avatar_url: '', + is_password_set: true, + }, + langGeniusVersionInfo: { + current_env: 'CLOUD', + current_version: '1.0.0', + latest_version: '1.1.0', + release_date: '', + release_notes: 'https://example.com/releases/1.1.0', + version: '1.0.0', + can_auto_update: false, + }, + isCurrentWorkspaceOwner: false, + }, mockPush: vi.fn(), mockLogout: vi.fn(), mockResetUser: vi.fn(), @@ -28,21 +49,19 @@ vi.mock('react-i18next', () => ({ })) vi.mock('@/context/app-context', () => ({ - useAppContext: () => ({ - userProfile: { - name: 'Ada Lovelace', - email: 'ada@example.com', - avatar_url: '', - }, - langGeniusVersionInfo: { - current_version: '1.0.0', - latest_version: '1.1.0', - release_notes: 'https://example.com/releases/1.1.0', - }, - isCurrentWorkspaceOwner: false, - }), + useAppContext: () => mockAppContextState, })) +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('@/context/provider-context', () => ({ useProviderContext: () => ({ isEducationAccount: false, @@ -62,7 +81,8 @@ vi.mock('@/context/i18n', () => ({ useDocLink: () => (path: string) => `https://docs.example.com${path}`, })) -vi.mock('@/service/use-common', () => ({ +vi.mock('@/service/use-common', async importOriginal => ({ + ...await importOriginal(), useLogout: () => ({ mutateAsync: mockLogout, }), diff --git a/web/__tests__/utils/mock-app-context-state.ts b/web/__tests__/utils/mock-app-context-state.ts index 5be87eee447..d41dc9a2b47 100644 --- a/web/__tests__/utils/mock-app-context-state.ts +++ b/web/__tests__/utils/mock-app-context-state.ts @@ -13,6 +13,7 @@ export type AppContextStateMockState = { } | null currentWorkspace?: { id?: string + name?: string } | null isCurrentWorkspaceManager?: boolean isCurrentWorkspaceOwner?: boolean @@ -22,6 +23,8 @@ export type AppContextStateMockState = { isLoadingWorkspacePermissionKeys?: boolean workspacePermissionKeys?: string[] langGeniusVersionInfo?: LangGeniusVersionResponse + refreshUserProfile?: () => void + refreshCurrentWorkspace?: () => void } type AppContextStateAtomKind @@ -32,11 +35,14 @@ type AppContextStateAtomKind | 'currentWorkspaceId' | 'workspaceRoleFlags' | 'isCurrentWorkspaceManager' + | 'isCurrentWorkspaceOwner' | 'currentWorkspaceLoading' | 'workspacePermissionKeys' | 'workspacePermissionKeysLoading' | 'langGeniusVersionInfo' | 'langGeniusCurrentVersion' + | 'refreshUserProfile' + | 'refreshCurrentWorkspace' type AppContextStateMockAtom = { [APP_CONTEXT_STATE_ATOM_KIND]: AppContextStateAtomKind @@ -57,6 +63,7 @@ const defaultUserProfile = { const defaultCurrentWorkspace = { id: 'workspace-1', + name: 'Workspace', } const defaultLangGeniusVersionInfo = { @@ -109,11 +116,14 @@ export const createAppContextStateAtomMock = async ( currentWorkspaceIdAtom: createMockAtom('currentWorkspaceId'), workspaceRoleFlagsAtom: createMockAtom('workspaceRoleFlags'), isCurrentWorkspaceManagerAtom: createMockAtom('isCurrentWorkspaceManager'), + isCurrentWorkspaceOwnerAtom: createMockAtom('isCurrentWorkspaceOwner'), currentWorkspaceLoadingAtom: createMockAtom('currentWorkspaceLoading'), workspacePermissionKeysAtom: createMockAtom('workspacePermissionKeys'), workspacePermissionKeysLoadingAtom: createMockAtom('workspacePermissionKeysLoading'), langGeniusVersionInfoAtom: createMockAtom('langGeniusVersionInfo'), langGeniusCurrentVersionAtom: createMockAtom('langGeniusCurrentVersion'), + refreshUserProfileAtom: createMockAtom('refreshUserProfile'), + refreshCurrentWorkspaceAtom: createMockAtom('refreshCurrentWorkspace'), } } @@ -162,6 +172,9 @@ export const createAppContextStateJotaiMock = async ( if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'isCurrentWorkspaceManager') return state.isCurrentWorkspaceManager ?? false + if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'isCurrentWorkspaceOwner') + return state.isCurrentWorkspaceOwner ?? false + if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'currentWorkspaceLoading') return state.isLoadingCurrentWorkspace ?? false @@ -179,5 +192,22 @@ export const createAppContextStateJotaiMock = async ( throw new Error(`Unsupported app context state atom: ${atom[APP_CONTEXT_STATE_ATOM_KIND]}`) }, + useSetAtom: (atom: unknown) => { + if (!isAppContextStateMockAtom(atom)) + return actual.useSetAtom(atom as Parameters[0]) + + if (!appContextStateMockRegistry) + throw new Error('App context state atom mock is not initialized') + + const state = appContextStateMockRegistry.getState() + + if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'refreshUserProfile') + return state.refreshUserProfile ?? (() => {}) + + if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'refreshCurrentWorkspace') + return state.refreshCurrentWorkspace ?? (() => {}) + + throw new Error(`Unsupported app context state write atom: ${atom[APP_CONTEXT_STATE_ATOM_KIND]}`) + }, } } diff --git a/web/app/account/(commonLayout)/delete-account/components/check-email.tsx b/web/app/account/(commonLayout)/delete-account/components/check-email.tsx index 7e3351a10a2..b37a4995f02 100644 --- a/web/app/account/(commonLayout)/delete-account/components/check-email.tsx +++ b/web/app/account/(commonLayout)/delete-account/components/check-email.tsx @@ -1,9 +1,10 @@ 'use client' import { Button } from '@langgenius/dify-ui/button' +import { Input } from '@langgenius/dify-ui/input' +import { useAtomValue } from 'jotai' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import Input from '@/app/components/base/input' -import { useAppContext } from '@/context/app-context' +import { userProfileEmailAtom } from '@/context/app-context-state' import Link from '@/next/link' import { useSendDeleteAccountEmail } from '../state' @@ -14,7 +15,7 @@ type DeleteAccountProps = { export default function CheckEmail(props: DeleteAccountProps) { const { t } = useTranslation() - const { userProfile } = useAppContext() + const userProfileEmail = useAtomValue(userProfileEmailAtom) const [userInputEmail, setUserInputEmail] = useState('') const { isPending: isSendingEmail, mutateAsync: getDeleteEmailVerifyCode } = useSendDeleteAccountEmail() @@ -45,7 +46,7 @@ export default function CheckEmail(props: DeleteAccountProps) { }} />
- +
diff --git a/web/app/account/(commonLayout)/delete-account/components/feed-back.tsx b/web/app/account/(commonLayout)/delete-account/components/feed-back.tsx index ead73b2319c..697c6df095c 100644 --- a/web/app/account/(commonLayout)/delete-account/components/feed-back.tsx +++ b/web/app/account/(commonLayout)/delete-account/components/feed-back.tsx @@ -3,9 +3,10 @@ import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { Textarea } from '@langgenius/dify-ui/textarea' import { toast } from '@langgenius/dify-ui/toast' +import { useAtomValue } from 'jotai' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useAppContext } from '@/context/app-context' +import { userProfileEmailAtom } from '@/context/app-context-state' import { useRouter } from '@/next/navigation' import { useLogout } from '@/service/use-common' import { useDeleteAccountFeedback } from '../state' @@ -17,7 +18,7 @@ type DeleteAccountProps = { export default function FeedBack(props: DeleteAccountProps) { const { t } = useTranslation() - const { userProfile } = useAppContext() + const userProfileEmail = useAtomValue(userProfileEmailAtom) const router = useRouter() const [userFeedback, setUserFeedback] = useState('') const { isPending, mutateAsync: sendFeedback } = useDeleteAccountFeedback() @@ -35,12 +36,12 @@ export default function FeedBack(props: DeleteAccountProps) { const handleSubmit = useCallback(async () => { try { - await sendFeedback({ feedback: userFeedback, email: userProfile.email }) + await sendFeedback({ feedback: userFeedback, email: userProfileEmail }) props.onConfirm() await handleSuccess() } catch (error) { console.error(error) } - }, [handleSuccess, userFeedback, sendFeedback, userProfile, props]) + }, [handleSuccess, userFeedback, sendFeedback, userProfileEmail, props]) const handleSkip = useCallback(() => { props.onCancel() 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 741580f6a5c..64065d65e15 100644 --- a/web/app/components/header/account-dropdown/__tests__/index.spec.tsx +++ b/web/app/components/header/account-dropdown/__tests__/index.spec.tsx @@ -43,6 +43,9 @@ vi.mock('@/app/components/base/theme-switcher', () => ({ const { mockSetTheme } = vi.hoisted(() => ({ mockSetTheme: vi.fn(), })) +const mockAppContextState = vi.hoisted(() => ({ + current: undefined as AppContextValue | undefined, +})) vi.mock('next-themes', () => ({ useTheme: () => ({ @@ -55,6 +58,16 @@ vi.mock('@/context/app-context', () => ({ useAppContext: vi.fn(), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current ?? {}) +}) + +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(), })) @@ -63,7 +76,8 @@ vi.mock('@/context/modal-context', () => ({ useModalContext: vi.fn(), })) -vi.mock('@/service/use-common', () => ({ +vi.mock('@/service/use-common', async importOriginal => ({ + ...await importOriginal(), useLogout: vi.fn(), })) @@ -150,6 +164,11 @@ const baseAppContextValue: AppContextValue = { workspacePermissionKeys: [], } +const setAppContextValue = (value: AppContextValue) => { + mockAppContextState.current = value + vi.mocked(useAppContext).mockReturnValue(value) +} + describe('AccountDropdown', () => { const mockPush = vi.fn() const mockLogout = vi.fn() @@ -170,7 +189,7 @@ describe('AccountDropdown', () => { mockConfig.IS_CLOUD_EDITION = false mockEnv.env.NEXT_PUBLIC_SITE_ABOUT = 'show' - vi.mocked(useAppContext).mockReturnValue(baseAppContextValue) + setAppContextValue(baseAppContextValue) vi.mocked(useProviderContext).mockReturnValue({ isEducationAccount: false, plan: { type: Plan.sandbox }, @@ -267,7 +286,7 @@ describe('AccountDropdown', () => { it('should show Compliance in Cloud Edition for workspace owner', () => { // Arrange mockConfig.IS_CLOUD_EDITION = true - vi.mocked(useAppContext).mockReturnValue({ + setAppContextValue({ ...baseAppContextValue, userProfile: { ...baseAppContextValue.userProfile, name: 'User' }, isCurrentWorkspaceOwner: true, @@ -286,7 +305,7 @@ describe('AccountDropdown', () => { it('should hide Compliance in Cloud Edition when user is not workspace owner', () => { // Arrange mockConfig.IS_CLOUD_EDITION = true - vi.mocked(useAppContext).mockReturnValue({ + setAppContextValue({ ...baseAppContextValue, isCurrentWorkspaceOwner: false, }) @@ -382,7 +401,7 @@ describe('AccountDropdown', () => { describe('Version Indicators', () => { it('should show orange indicator when version is not latest', () => { // Arrange - vi.mocked(useAppContext).mockReturnValue({ + setAppContextValue({ ...baseAppContextValue, userProfile: { ...baseAppContextValue.userProfile, name: 'User' }, langGeniusVersionInfo: { @@ -402,7 +421,7 @@ describe('AccountDropdown', () => { it('should show green indicator when version is latest', () => { // Arrange - vi.mocked(useAppContext).mockReturnValue({ + setAppContextValue({ ...baseAppContextValue, userProfile: { ...baseAppContextValue.userProfile, name: 'User' }, langGeniusVersionInfo: { diff --git a/web/app/components/header/account-dropdown/default-menu-content.tsx b/web/app/components/header/account-dropdown/default-menu-content.tsx index bb0aded8e8c..ac5e981d971 100644 --- a/web/app/components/header/account-dropdown/default-menu-content.tsx +++ b/web/app/components/header/account-dropdown/default-menu-content.tsx @@ -10,12 +10,13 @@ import { } from '@langgenius/dify-ui/dropdown-menu' import { StatusDot } from '@langgenius/dify-ui/status-dot' import { useSuspenseQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' import PremiumBadge from '@/app/components/base/premium-badge' import ThemeSwitcher from '@/app/components/base/theme-switcher' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { IS_CLOUD_EDITION } from '@/config' -import { useAppContext } from '@/context/app-context' +import { isCurrentWorkspaceOwnerAtom, langGeniusVersionInfoAtom, userProfileAtom } from '@/context/app-context-state' import { useDocLink } from '@/context/i18n' import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' @@ -119,7 +120,9 @@ export function DefaultMenuContent({ const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { t } = useTranslation() const docLink = useDocLink() - const { userProfile, langGeniusVersionInfo, isCurrentWorkspaceOwner } = useAppContext() + const userProfile = useAtomValue(userProfileAtom) + const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom) + const isCurrentWorkspaceOwner = useAtomValue(isCurrentWorkspaceOwnerAtom) const { isEducationAccount } = useProviderContext() const { setShowAccountSettingModal } = useModalContext() diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx index dbadfc1f64c..518398c4c62 100644 --- a/web/app/components/header/account-dropdown/index.tsx +++ b/web/app/components/header/account-dropdown/index.tsx @@ -8,11 +8,12 @@ import { DropdownMenuContent, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' +import { useAtomValue } from 'jotai' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { resetUser } from '@/app/components/base/amplitude/utils' import { useSetEducationExpiredHasNoticed, useSetEducationReverifyHasNoticed, useSetEducationReverifyPrevExpireAt } from '@/app/education-apply/storage' -import { useAppContext } from '@/context/app-context' +import { langGeniusVersionInfoAtom, userProfileAtom } from '@/context/app-context-state' import { useRouter } from '@/next/navigation' import { useLogout } from '@/service/use-common' import AccountAbout from '../account-about' @@ -37,7 +38,8 @@ export default function AppSelector({ const [aboutVisible, setAboutVisible] = useState(false) const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false) const { t } = useTranslation() - const { userProfile, langGeniusVersionInfo } = useAppContext() + const userProfile = useAtomValue(userProfileAtom) + const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom) const clearEducationReverifyPrevExpireAt = useSetEducationReverifyPrevExpireAt() const clearEducationReverifyHasNoticed = useSetEducationReverifyHasNoticed() const clearEducationExpiredHasNoticed = useSetEducationExpiredHasNoticed() diff --git a/web/app/components/header/account-dropdown/main-nav-menu-content.tsx b/web/app/components/header/account-dropdown/main-nav-menu-content.tsx index 3ae90caffcb..2faf2ad7f46 100644 --- a/web/app/components/header/account-dropdown/main-nav-menu-content.tsx +++ b/web/app/components/header/account-dropdown/main-nav-menu-content.tsx @@ -16,11 +16,12 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, } from '@langgenius/dify-ui/dropdown-menu' +import { useAtomValue } from 'jotai' import { useTheme } from 'next-themes' import { useTranslation } from 'react-i18next' import PremiumBadge from '@/app/components/base/premium-badge' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' -import { useAppContext } from '@/context/app-context' +import { userProfileAtom } from '@/context/app-context-state' import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' import Link from '@/next/link' @@ -87,7 +88,7 @@ export function MainNavMenuContent({ onLogout, }: MainNavMenuContentProps) { const { t } = useTranslation() - const { userProfile } = useAppContext() + const userProfile = useAtomValue(userProfileAtom) const { isEducationAccount } = useProviderContext() const { setShowAccountSettingModal } = useModalContext() diff --git a/web/app/components/header/account-setting/access-rules-page/__tests__/access-rule-section.spec.tsx b/web/app/components/header/account-setting/access-rules-page/__tests__/access-rule-section.spec.tsx index 35dd91459b6..f2a8fbc8f4d 100644 --- a/web/app/components/header/account-setting/access-rules-page/__tests__/access-rule-section.spec.tsx +++ b/web/app/components/header/account-setting/access-rules-page/__tests__/access-rule-section.spec.tsx @@ -15,6 +15,18 @@ 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: mocks.workspacePermissionKeys, + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + const rule: AccessPolicyWithBindings = { policy: { id: 'app-rule-1', diff --git a/web/app/components/header/account-setting/access-rules-page/access-rule-section.tsx b/web/app/components/header/account-setting/access-rules-page/access-rule-section.tsx index fe1498a64f0..fc5bcbcc4b4 100644 --- a/web/app/components/header/account-setting/access-rules-page/access-rule-section.tsx +++ b/web/app/components/header/account-setting/access-rules-page/access-rule-section.tsx @@ -3,11 +3,12 @@ import type { AccessPolicyWithBindings } from '@/models/access-control' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { useAtomValue } from 'jotai' import { memo, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import Loading from '@/app/components/base/loading' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { workspacePermissionKeysAtom } from '@/context/app-context-state' import { hasPermission } from '@/utils/permission' import AccessRuleRow from './access-rule-row' @@ -46,7 +47,7 @@ const AccessRuleSection = ({ const [expanded, setExpanded] = useState(defaultExpanded) const listRef = useRef(null) const anchorRef = useRef(null) - const workspacePermissionKeys = useAppContextWithSelector(s => s.workspacePermissionKeys) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canManage = hasPermission(workspacePermissionKeys, 'workspace.role.manage') const ruleCount = totalCount ?? rules.length diff --git a/web/app/components/header/account-setting/api-based-extension-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/api-based-extension-page/__tests__/index.spec.tsx index af0536fd282..f5f475655e6 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/__tests__/index.spec.tsx @@ -24,6 +24,18 @@ 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.current, + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/service/client', () => ({ consoleQuery: { apiBasedExtension: { diff --git a/web/app/components/header/account-setting/api-based-extension-page/index.tsx b/web/app/components/header/account-setting/api-based-extension-page/index.tsx index 78faacf711f..45d05fdf875 100644 --- a/web/app/components/header/account-setting/api-based-extension-page/index.tsx +++ b/web/app/components/header/account-setting/api-based-extension-page/index.tsx @@ -2,11 +2,12 @@ import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api- import type { ReactNode } from 'react' import { Button } from '@langgenius/dify-ui/button' import { useQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { SearchInput } from '@/app/components/base/search-input' import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton' -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 { Empty } from './empty' @@ -51,7 +52,7 @@ export function ApiBasedExtensionPage({ layout, }: ApiBasedExtensionPageProps = {}) { const { t } = useTranslation() - const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canManage = hasPermission(workspacePermissionKeys, 'api_extension.manage') const { data: apiBasedExtensions = [], isPending: isLoading } = useQuery(consoleQuery.apiBasedExtension.get.queryOptions()) const [dialogState, setDialogState] = useState(null) diff --git a/web/app/components/header/account-setting/index.tsx b/web/app/components/header/account-setting/index.tsx index 3d0be5fb7ef..2766957cd3a 100644 --- a/web/app/components/header/account-setting/index.tsx +++ b/web/app/components/header/account-setting/index.tsx @@ -4,6 +4,7 @@ import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { ScrollArea } from '@langgenius/dify-ui/scroll-area' import { useSuspenseQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import BillingPage from '@/app/components/billing/billing-page' @@ -12,7 +13,7 @@ import { ACCOUNT_SETTING_TAB, } from '@/app/components/header/account-setting/constants' import MenuDialog from '@/app/components/header/account-setting/menu-dialog' -import { useAppContext } from '@/context/app-context' +import { workspacePermissionKeysAtom } from '@/context/app-context-state' import { useProviderContext } from '@/context/provider-context' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' @@ -54,7 +55,7 @@ export default function AccountSetting({ const { t } = useTranslation() const { enableBilling, enableReplaceWebAppLogo } = useProviderContext() const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const { workspacePermissionKeys } = useAppContext() + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const isRbacEnabled = systemFeatures.rbac_enabled const canManageWorkspaceRoles = isRbacEnabled && hasPermission(workspacePermissionKeys, 'workspace.role.manage') const canViewBilling = enableBilling && hasPermission(workspacePermissionKeys, BillingPermission.View) 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 29cae8e0a68..d93d0c10571 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 @@ -14,7 +14,19 @@ import { useUpdateRolesOfMember } from '@/service/access-control/use-member-role import { useMembers } from '@/service/use-common' import MembersPage from '../index' +const mockAppContextState = vi.hoisted(() => ({ + current: {} as Partial, +})) + 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, () => mockAppContextState.current) +}) +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) vi.mock('@/context/provider-context') vi.mock('@/hooks/use-format-time-from-now') vi.mock('@/service/access-control/use-member-roles') @@ -41,6 +53,11 @@ const createRole = (overrides: Partial): Role => ({ ...overrides, }) +const setAppContextValue = (value: AppContextValue) => { + mockAppContextState.current = value + vi.mocked(useAppContext).mockReturnValue(value) +} + vi.mock('../edit-workspace-modal', () => ({ default: ({ onCancel }: { onCancel: () => void }) => (
@@ -181,7 +198,7 @@ describe('MembersPage', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(useAppContext).mockReturnValue({ + setAppContextValue({ userProfile: { email: 'owner@example.com' }, currentWorkspace: { name: 'Test Workspace', role: 'owner' } as ICurrentWorkspace, isCurrentWorkspaceOwner: true, @@ -289,7 +306,7 @@ describe('MembersPage', () => { }) it('should hide manager controls for non-owner non-manager users', () => { - vi.mocked(useAppContext).mockReturnValue({ + setAppContextValue({ userProfile: { email: 'admin@example.com' }, currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace, isCurrentWorkspaceOwner: false, @@ -390,7 +407,7 @@ describe('MembersPage', () => { }) it('should show invite button when user is manager but not owner', () => { - vi.mocked(useAppContext).mockReturnValue({ + setAppContextValue({ userProfile: { email: 'admin@example.com' }, currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace, isCurrentWorkspaceOwner: false, @@ -405,7 +422,7 @@ describe('MembersPage', () => { }) it('should allow admins to operate other non-owner members only', () => { - vi.mocked(useAppContext).mockReturnValue({ + setAppContextValue({ userProfile: { email: 'admin@example.com' }, currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace, isCurrentWorkspaceOwner: false, @@ -483,7 +500,7 @@ describe('MembersPage', () => { }) it('should render role badge names from account roles', () => { - vi.mocked(useAppContext).mockReturnValue({ + setAppContextValue({ userProfile: { email: 'admin@example.com' }, currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace, isCurrentWorkspaceOwner: false, @@ -551,7 +568,7 @@ describe('MembersPage', () => { it('should not allow assigning roles from member details when target is current user', async () => { const user = userEvent.setup() - vi.mocked(useAppContext).mockReturnValue({ + setAppContextValue({ userProfile: { email: 'admin@example.com' }, currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace, isCurrentWorkspaceOwner: false, 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 43fcce16f74..7ef5e69bacd 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 @@ -8,6 +8,16 @@ import { useWorkspacePermissions } from '@/service/use-workspace' import InviteButton from '../invite-button' 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: { id: 'workspace-id' }, + })) +}) +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) vi.mock('@/service/use-workspace') describe('InviteButton', () => { 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 e134f8f93c6..96cfd07cd54 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,4 +1,5 @@ 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' @@ -10,6 +11,9 @@ type DialogProps = { } let latestOnOpenChange: DialogProps['onOpenChange'] +const mockAppContextState = vi.hoisted(() => ({ + current: {} as Partial, +})) vi.mock('@langgenius/dify-ui/dialog', () => ({ Dialog: ({ children, onOpenChange }: DialogProps) => { @@ -29,14 +33,26 @@ vi.mock('@/context/app-context', () => ({ useAppContext: vi.fn(), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + describe('EditWorkspaceModal dialog lifecycle', () => { beforeEach(() => { vi.clearAllMocks() latestOnOpenChange = undefined - vi.mocked(useAppContext).mockReturnValue({ + const appContextValue = { currentWorkspace: { name: 'Test Workspace' }, isCurrentWorkspaceOwner: true, - } as never) + } as never + mockAppContextState.current = appContextValue + vi.mocked(useAppContext).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 1b85319613a..5ec4cb538d7 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 @@ -10,10 +10,21 @@ import EditWorkspaceModal from '../index' const toastMocks = vi.hoisted(() => ({ mockNotify: vi.fn(), })) +const mockAppContextState = vi.hoisted(() => ({ + current: {} as Partial, +})) const getSaveButton = () => screen.getByRole('button', { name: /operation\.(save|saving)/i }) 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, () => mockAppContextState.current) +}) +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) vi.mock('@/service/common') vi.mock('@langgenius/dify-ui/toast', () => ({ default: { @@ -34,10 +45,12 @@ describe('EditWorkspaceModal', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(useAppContext).mockReturnValue({ + const appContextValue = { currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace, isCurrentWorkspaceOwner: true, - } as unknown as AppContextValue) + } as unknown as AppContextValue + mockAppContextState.current = appContextValue + vi.mocked(useAppContext).mockReturnValue(appContextValue) }) afterEach(() => { diff --git a/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx b/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx index f9d84d414b4..7d17366df84 100644 --- a/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/edit-workspace-modal/index.tsx @@ -2,11 +2,12 @@ import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' +import { Input } from '@langgenius/dify-ui/input' import { toast } from '@langgenius/dify-ui/toast' +import { useAtomValue } from 'jotai' import { useId, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import Input from '@/app/components/base/input' -import { useAppContext } from '@/context/app-context' +import { currentWorkspaceAtom, isCurrentWorkspaceOwnerAtom } from '@/context/app-context-state' import { updateWorkspaceInfo } from '@/service/common' type IEditWorkspaceModalProps = { @@ -14,7 +15,8 @@ type IEditWorkspaceModalProps = { } const EditWorkspaceModal = ({ onCancel }: IEditWorkspaceModalProps) => { const { t } = useTranslation() - const { currentWorkspace, isCurrentWorkspaceOwner } = useAppContext() + const currentWorkspace = useAtomValue(currentWorkspaceAtom) + const isCurrentWorkspaceOwner = useAtomValue(isCurrentWorkspaceOwnerAtom) const [name, setName] = useState(currentWorkspace.name) const [isSubmitting, setIsSubmitting] = useState(false) const inputId = useId() @@ -82,7 +84,6 @@ const EditWorkspaceModal = ({ onCancel }: IEditWorkspaceModalProps) => { { diff --git a/web/app/components/header/account-setting/members-page/index.tsx b/web/app/components/header/account-setting/members-page/index.tsx index 478952e5c6d..bd745507367 100644 --- a/web/app/components/header/account-setting/members-page/index.tsx +++ b/web/app/components/header/account-setting/members-page/index.tsx @@ -4,12 +4,13 @@ import type { InvitationResult, Member } from '@/models/common' import { toast } from '@langgenius/dify-ui/toast' import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useSuspenseQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { NUM_INFINITE } from '@/app/components/billing/config' import { Plan } from '@/app/components/billing/type' import UpgradeBtn from '@/app/components/billing/upgrade-btn' -import { useAppContext } from '@/context/app-context' +import { currentWorkspaceAtom, isCurrentWorkspaceOwnerAtom, userProfileEmailAtom, workspacePermissionKeysAtom } from '@/context/app-context-state' import { useLocale } from '@/context/i18n' import { useProviderContext } from '@/context/provider-context' import { systemFeaturesQueryOptions } from '@/features/system-features/client' @@ -30,7 +31,10 @@ const MembersPage = () => { const locale = useLocale() const language = getAccessControlTemplateLanguage(locale) - const { userProfile, currentWorkspace, isCurrentWorkspaceOwner, workspacePermissionKeys } = useAppContext() + const userProfileEmail = useAtomValue(userProfileEmailAtom) + const currentWorkspace = useAtomValue(currentWorkspaceAtom) + const isCurrentWorkspaceOwner = useAtomValue(isCurrentWorkspaceOwnerAtom) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const { data, refetch } = useMembers(language) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const [inviteModalVisible, setInviteModalVisible] = useState(false) @@ -162,7 +166,7 @@ const MembersPage = () => { key={account.id} member={account} roles={account.roles} - isCurrentUser={userProfile.email === account.email} + isCurrentUser={userProfileEmail === account.email} canManage={canManageMembers} canTransferOwnership={isCurrentWorkspaceOwner && isAllowTransferWorkspace} allowMultipleRoles={systemFeatures.rbac_enabled} @@ -213,7 +217,7 @@ const MembersPage = () => { canAssignRoles={ canManageMembers && detailsMember.role !== 'owner' - && userProfile.email !== detailsMember.email + && userProfileEmail !== detailsMember.email } allowMultipleRoles={systemFeatures.rbac_enabled} onClose={handleCloseDetails} diff --git a/web/app/components/header/account-setting/members-page/invite-button.tsx b/web/app/components/header/account-setting/members-page/invite-button.tsx index e0a9408515a..e08415eedc9 100644 --- a/web/app/components/header/account-setting/members-page/invite-button.tsx +++ b/web/app/components/header/account-setting/members-page/invite-button.tsx @@ -1,9 +1,10 @@ import { Button } from '@langgenius/dify-ui/button' import { RiUserAddLine } from '@remixicon/react' import { useSuspenseQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' -import { useAppContext } from '@/context/app-context' +import { currentWorkspaceIdAtom } from '@/context/app-context-state' import { systemFeaturesQueryOptions } from '@/features/system-features/client' import { useWorkspacePermissions } from '@/service/use-workspace' @@ -14,9 +15,9 @@ type InviteButtonProps = { const InviteButton = (props: InviteButtonProps) => { const { t } = useTranslation() - const { currentWorkspace } = useAppContext() + const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) - const { data: workspacePermissions, isFetching: isFetchingWorkspacePermissions } = useWorkspacePermissions(currentWorkspace!.id, systemFeatures.branding.enabled) + const { data: workspacePermissions, isFetching: isFetchingWorkspacePermissions } = useWorkspacePermissions(currentWorkspaceId, systemFeatures.branding.enabled) if (systemFeatures.branding.enabled) { if (isFetchingWorkspacePermissions) { return 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 07371740bd8..db02b773cc7 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 @@ -11,8 +11,19 @@ import TransferOwnershipModal from '../index' const toastMocks = vi.hoisted(() => ({ mockNotify: vi.fn(), })) +const mockAppContextState = vi.hoisted(() => ({ + current: {} as Partial, +})) 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, () => mockAppContextState.current) +}) +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) vi.mock('@/service/common') vi.mock('@/service/use-common') vi.mock('@langgenius/dify-ui/toast', () => ({ @@ -40,10 +51,12 @@ describe('TransferOwnershipModal', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(useAppContext).mockReturnValue({ + const appContextValue = { currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace, userProfile: { email: 'owner@example.com', id: 'owner-id' }, - } as unknown as AppContextValue) + } as unknown as AppContextValue + mockAppContextState.current = appContextValue + vi.mocked(useAppContext).mockReturnValue(appContextValue) vi.mocked(useMembers).mockReturnValue({ data: { accounts: [] }, diff --git a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx index 7bfb3ee3f1f..ee198a7b555 100644 --- a/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx +++ b/web/app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx @@ -1,11 +1,12 @@ import { Button } from '@langgenius/dify-ui/button' 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 * as React from 'react' import { useCallback, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' -import Input from '@/app/components/base/input' -import { useAppContext } from '@/context/app-context' +import { currentWorkspaceAtom, userProfileAtom } from '@/context/app-context-state' import { ownershipTransfer, sendOwnerEmail, verifyOwnerEmail } from '@/service/common' import MemberSelector from './member-selector' @@ -13,15 +14,22 @@ type Props = Readonly<{ show: boolean onClose: () => void }> -enum STEP { - start = 'start', - verify = 'verify', - transfer = 'transfer', +const STEP = { + start: 'start', + verify: 'verify', + transfer: 'transfer', } +type Step = typeof STEP[keyof typeof STEP] + +const getErrorMessage = (error: unknown) => { + return error instanceof Error ? error.message : '' +} + const TransferOwnershipModal = ({ onClose, show }: Props) => { const { t } = useTranslation() - const { currentWorkspace, userProfile } = useAppContext() - const [step, setStep] = useState(STEP.start) + const currentWorkspace = useAtomValue(currentWorkspaceAtom) + const userProfile = useAtomValue(userProfileAtom) + const [step, setStep] = useState(STEP.start) const [code, setCode] = useState('') const [time, setTime] = useState(0) const [stepToken, setStepToken] = useState('') @@ -71,7 +79,7 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => { } } catch (error) { - toast.error(`Error verifying email: ${error ? (error as any).message : ''}`) + toast.error(`Error verifying email: ${getErrorMessage(error)}`) } } const sendCodeToOriginEmail = async () => { @@ -96,7 +104,7 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => { globalThis.location.reload() } catch (error) { - toast.error(`Error ownership transfer: ${error ? (error as any).message : ''}`) + toast.error(`Error ownership transfer: ${getErrorMessage(error)}`) } finally { setIsTransfer(false) diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list-item.spec.tsx index 6b6540ec261..065baa2496f 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list-item.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list-item.spec.tsx @@ -22,6 +22,18 @@ 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('@/context/provider-context', () => ({ useProviderContext: () => ({ plan: { type: mockPlanType }, diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list.spec.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list.spec.tsx index c307407c0fd..1d5055a0741 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/__tests__/model-list.spec.tsx @@ -11,6 +11,18 @@ vi.mock('@/context/app-context', () => ({ selector({ workspacePermissionKeys: mockWorkspacePermissionKeys }), })) +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('@/context/modal-context', () => ({ useModalContextSelector: (selector: (state: { setShowModelLoadBalancingModal: typeof mockSetShowModelLoadBalancingModal }) => unknown) => selector({ setShowModelLoadBalancingModal: mockSetShowModelLoadBalancingModal }), diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx index 90bd54c5e63..598254ceeda 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx @@ -7,6 +7,7 @@ import type { PluginDetail } from '@/app/components/plugins/types' import { cn } from '@langgenius/dify-ui/cn' import { useQuery } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' import { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { @@ -14,7 +15,7 @@ import { ManageCustomModelCredentials, } from '@/app/components/header/account-setting/model-provider-page/model-auth' import { IS_CE_EDITION } from '@/config' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { workspacePermissionKeysAtom } from '@/context/app-context-state' import { useProviderContextSelector } from '@/context/provider-context' import { useCredentialPermissions } from '@/hooks/use-credential-permissions' import { renderI18nObject } from '@/i18n-config' @@ -68,7 +69,7 @@ const ProviderAddedCard: FC = ({ })) const hasModelList = hasFetchedModelList && !!modelList.length const showCollapsedSection = !expanded || !hasFetchedModelList - const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const showModelProvider = systemConfig.enabled && MODEL_PROVIDER_QUOTA_GET_PAID.includes(currentProviderName as ModelProviderQuotaGetPaid) && !IS_CE_EDITION const canConfigureModels = hasPermission(workspacePermissionKeys, 'plugin.model_config') const { canUseCredential, canCreateCredential, canManageCredential } = useCredentialPermissions() diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx index ba890ab8c90..ea642daca49 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list-item.tsx @@ -4,12 +4,13 @@ import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/pop import { Switch } from '@langgenius/dify-ui/switch' import { useQueryClient } from '@tanstack/react-query' import { useDebounceFn } from 'ahooks' +import { useAtomValue } from 'jotai' import { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import Badge from '@/app/components/base/badge' import { Balance } from '@/app/components/base/icons/src/vender/line/financeAndECommerce' import { Plan } from '@/app/components/billing/type' -import { useAppContext } from '@/context/app-context' +import { workspacePermissionKeysAtom } from '@/context/app-context-state' import { useProviderContext, useProviderContextSelector } from '@/context/provider-context' import { consoleQuery } from '@/service/client' import { disableModel, enableModel } from '@/service/common' @@ -32,7 +33,7 @@ const ModelListItem = ({ model, provider, isConfigurable, onChange, onModifyLoad const { t } = useTranslation() const { plan } = useProviderContext() const modelLoadBalancingEnabled = useProviderContextSelector(state => state.modelLoadBalancingEnabled) - const { workspacePermissionKeys } = useAppContext() + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canConfigureModels = hasPermission(workspacePermissionKeys, 'plugin.model_config') const queryClient = useQueryClient() const updateModelList = useUpdateModelList() diff --git a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list.tsx b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list.tsx index 0b818542e5c..b1a6ceccf27 100644 --- a/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list.tsx +++ b/web/app/components/header/account-setting/model-provider-page/provider-added-card/model-list.tsx @@ -4,13 +4,14 @@ import type { ModelItem, ModelProvider, } from '../declarations' +import { useAtomValue } from 'jotai' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { AddCustomModel, ManageCustomModelCredentials, } from '@/app/components/header/account-setting/model-provider-page/model-auth' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { workspacePermissionKeysAtom } from '@/context/app-context-state' import { useModalContextSelector } from '@/context/modal-context' import { hasPermission } from '@/utils/permission' import { @@ -33,7 +34,7 @@ const ModelList: FC = ({ }) => { const { t } = useTranslation() const configurativeMethods = provider.configurate_methods.filter(method => method !== ConfigurationMethodEnum.fetchFromRemote) - const workspacePermissionKeys = useAppContextWithSelector(state => state.workspacePermissionKeys) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canConfigureModels = hasPermission(workspacePermissionKeys, 'plugin.model_config') const isConfigurable = configurativeMethods.includes(ConfigurationMethodEnum.customizableModel) const setShowModelLoadBalancingModal = useModalContextSelector(state => state.setShowModelLoadBalancingModal) @@ -58,13 +59,14 @@ const ModelList: FC = ({ {t('modelProvider.modelsNum', { ns: 'common', num: models.length })} - onCollapse()} > {t('modelProvider.modelsNum', { ns: 'common', num: models.length })} - + { isConfigurable && canConfigureModels && ( diff --git a/web/app/components/header/account-setting/model-provider-page/system-model-selector/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/system-model-selector/__tests__/index.spec.tsx index dc4d716b6ce..4bbf4ec7bb6 100644 --- a/web/app/components/header/account-setting/model-provider-page/system-model-selector/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/system-model-selector/__tests__/index.spec.tsx @@ -40,6 +40,18 @@ 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('@/context/provider-context', () => ({ useProviderContext: () => ({ textGenerationModelList: [], diff --git a/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx b/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx index 6fe3fc917f2..ece50247ec8 100644 --- a/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/system-model-selector/index.tsx @@ -12,10 +12,11 @@ import { DialogTitle, } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' +import { useAtomValue } from 'jotai' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { Infotip } from '@/app/components/base/infotip' -import { useAppContext } from '@/context/app-context' +import { workspacePermissionKeysAtom } from '@/context/app-context-state' import { useProviderContext } from '@/context/provider-context' import { updateDefaultModel } from '@/service/common' import { hasPermission } from '@/utils/permission' @@ -68,7 +69,7 @@ const SystemModel: FC = ({ onOpenMarketplace, }) => { const { t } = useTranslation() - const { workspacePermissionKeys } = useAppContext() + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const { textGenerationModelList } = useProviderContext() const canManageSystemDefaultModel = hasPermission(workspacePermissionKeys, 'plugin.model_config') const updateModelList = useUpdateModelList() diff --git a/web/app/components/header/account-setting/permissions-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/permissions-page/__tests__/index.spec.tsx index b37c07d1054..c0c0efb4f95 100644 --- a/web/app/components/header/account-setting/permissions-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/permissions-page/__tests__/index.spec.tsx @@ -26,6 +26,18 @@ vi.mock('@/context/app-context', () => ({ } as AppContextValue)), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => ({ + workspacePermissionKeys: mocks.workspacePermissionKeys, + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/service/access-control/use-workspace-roles', () => ({ useCreateWorkspaceRole: vi.fn(), useUpdateWorkspaceRole: vi.fn(), diff --git a/web/app/components/header/account-setting/permissions-page/index.tsx b/web/app/components/header/account-setting/permissions-page/index.tsx index e5d3408106d..f1fa6572ca9 100644 --- a/web/app/components/header/account-setting/permissions-page/index.tsx +++ b/web/app/components/header/account-setting/permissions-page/index.tsx @@ -4,9 +4,10 @@ import type { RoleModalMode, submitRoleData } from './role-modal' import type { Role } from '@/models/access-control' import { Button } from '@langgenius/dify-ui/button' import { toast } from '@langgenius/dify-ui/toast' +import { useAtomValue } from 'jotai' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { workspacePermissionKeysAtom } from '@/context/app-context-state' import { useLocale } from '@/context/i18n' import { getAccessControlTemplateLanguage } from '@/i18n-config/language' import { useCreateWorkspaceRole, useUpdateWorkspaceRole } from '@/service/access-control/use-workspace-roles' @@ -32,7 +33,7 @@ const PermissionsPage = ({ containerRef }: PermissionsPageProps) => { const [modalState, setModalState] = useState(null) const anchorRef = useRef(null) - const workspacePermissionKeys = useAppContextWithSelector(s => s.workspacePermissionKeys) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const language = useMemo(() => getAccessControlTemplateLanguage(locale), [locale]) diff --git a/web/app/components/header/account-setting/permissions-page/role-list/__tests__/row-menu.spec.tsx b/web/app/components/header/account-setting/permissions-page/role-list/__tests__/row-menu.spec.tsx index 90558b49896..46412f3f16f 100644 --- a/web/app/components/header/account-setting/permissions-page/role-list/__tests__/row-menu.spec.tsx +++ b/web/app/components/header/account-setting/permissions-page/role-list/__tests__/row-menu.spec.tsx @@ -36,6 +36,18 @@ 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/access-control/use-workspace-roles', () => ({ useCopyWorkspaceRole: () => ({ mutate: mockCopyRole, diff --git a/web/app/components/header/account-setting/permissions-page/role-list/row-menu.tsx b/web/app/components/header/account-setting/permissions-page/role-list/row-menu.tsx index 678357ea4bc..145a83c3467 100644 --- a/web/app/components/header/account-setting/permissions-page/role-list/row-menu.tsx +++ b/web/app/components/header/account-setting/permissions-page/role-list/row-menu.tsx @@ -18,10 +18,11 @@ import { DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' import { toast } from '@langgenius/dify-ui/toast' +import { useAtomValue } from 'jotai' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' -import { useSelector as useAppContextWithSelector } from '@/context/app-context' +import { workspacePermissionKeysAtom } from '@/context/app-context-state' import { useCopyWorkspaceRole, useDeleteWorkspaceRole } from '@/service/access-control/use-workspace-roles' import { hasPermission } from '@/utils/permission' import { CopyMembersConfirmDialog } from './copy-members-confirm-dialog' @@ -44,7 +45,7 @@ const RowMenu = ({ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [showCopyMembersConfirm, setShowCopyMembersConfirm] = useState(false) - const workspacePermissionKeys = useAppContextWithSelector(s => s.workspacePermissionKeys) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canManageRoles = hasPermission(workspacePermissionKeys, 'workspace.role.manage') const handleView = useCallback(() => onView?.(role), [onView, role]) diff --git a/web/app/components/header/account-setting/preference-page/__tests__/index.spec.tsx b/web/app/components/header/account-setting/preference-page/__tests__/index.spec.tsx index 55ee81481ad..db47bfe571d 100644 --- a/web/app/components/header/account-setting/preference-page/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/preference-page/__tests__/index.spec.tsx @@ -75,6 +75,19 @@ 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: mockUserProfile, + refreshUserProfile: mockMutateUserProfile, + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/context/i18n', () => ({ useLocale: () => mockLocale, })) diff --git a/web/app/components/header/account-setting/preference-page/index.tsx b/web/app/components/header/account-setting/preference-page/index.tsx index 1574d254cd2..c8a62fb90c5 100644 --- a/web/app/components/header/account-setting/preference-page/index.tsx +++ b/web/app/components/header/account-setting/preference-page/index.tsx @@ -2,10 +2,11 @@ import type { Locale } from '@/i18n-config' import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' import { toast } from '@langgenius/dify-ui/toast' +import { useAtomValue, useSetAtom } from 'jotai' import { useTheme } from 'next-themes' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useAppContext } from '@/context/app-context' +import { refreshUserProfileAtom, userProfileAtom } from '@/context/app-context-state' import { useLocale } from '@/context/i18n' import { setLocaleOnClient } from '@/i18n-config' import { languages } from '@/i18n-config/language' @@ -35,7 +36,8 @@ const isThemeOption = (value: string): value is ThemeOption => { export default function PreferencePage() { const locale = useLocale() - const { userProfile, mutateUserProfile } = useAppContext() + const userProfile = useAtomValue(userProfileAtom) + const refreshUserProfile = useSetAtom(refreshUserProfileAtom) const [editing, setEditing] = useState(false) const { t } = useTranslation() const router = useRouter() @@ -77,7 +79,7 @@ export default function PreferencePage() { try { await updateUserProfile({ url, body: { [bodyKey]: item.value } }) toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' })) - mutateUserProfile() + refreshUserProfile() } catch (e) { toast.error((e as Error).message) diff --git a/web/app/components/header/account-setting/update-setting-dialog.tsx b/web/app/components/header/account-setting/update-setting-dialog.tsx index 6e512b12af4..2d72c46cf3b 100644 --- a/web/app/components/header/account-setting/update-setting-dialog.tsx +++ b/web/app/components/header/account-setting/update-setting-dialog.tsx @@ -7,12 +7,13 @@ import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogCloseButton, DialogContent, DialogTitle, DialogTrigger } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' +import { useAtomValue } from 'jotai' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { convertTimezoneToOffsetStr } from '@/app/components/base/date-and-time-picker/utils/dayjs' import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from '@/app/components/plugins/reference-setting-modal/auto-update-setting/types' import { convertLocalSecondsToUTCDaySeconds, convertUTCDaySecondsToLocalSeconds, dayjsToTimeOfDay, timeOfDayToDayjs } from '@/app/components/plugins/reference-setting-modal/auto-update-setting/utils' -import { useAppContext } from '@/context/app-context' +import { userProfileAtom } from '@/context/app-context-state' import { useMutationPluginAutoUpgradeSettings, usePluginAutoUpgradeSettings } from '@/service/use-plugins' import UpdateSettingDialogForm from './update-setting-dialog-form' @@ -26,7 +27,7 @@ const UpdateSettingDialog = ({ disabled = false, }: Props) => { const { t } = useTranslation() - const { userProfile } = useAppContext() + const userProfile = useAtomValue(userProfileAtom) const timezone = userProfile.timezone || 'UTC' const { data: autoUpgradeSetting, 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 81076e773fe..98832145dc3 100644 --- a/web/app/components/header/env-nav/__tests__/index.spec.tsx +++ b/web/app/components/header/env-nav/__tests__/index.spec.tsx @@ -4,10 +4,24 @@ import { vi } from 'vitest' import { useAppContext } from '@/context/app-context' import EnvNav from '../index' +const mockAppContextState = vi.hoisted(() => ({ + current: {} as Partial, +})) + vi.mock('@/context/app-context', () => ({ useAppContext: vi.fn(), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + describe('EnvNav', () => { const mockUseAppContext = vi.mocked(useAppContext) @@ -16,33 +30,39 @@ describe('EnvNav', () => { }) it('should render null when environment is PRODUCTION', () => { - mockUseAppContext.mockReturnValue({ + const appContextValue = { langGeniusVersionInfo: { current_env: 'PRODUCTION', }, - } as unknown as AppContextValue) + } as unknown as AppContextValue + mockAppContextState.current = appContextValue + mockUseAppContext.mockReturnValue(appContextValue) const { container } = render() expect(container.firstChild).toBeNull() }) it('should render TESTING tag and icon when environment is TESTING', () => { - mockUseAppContext.mockReturnValue({ + const appContextValue = { langGeniusVersionInfo: { current_env: 'TESTING', }, - } as unknown as AppContextValue) + } as unknown as AppContextValue + mockAppContextState.current = appContextValue + mockUseAppContext.mockReturnValue(appContextValue) render() expect(screen.getByText('common.environment.testing')).toBeInTheDocument() }) it('should render DEVELOPMENT tag and icon when environment is DEVELOPMENT', () => { - mockUseAppContext.mockReturnValue({ + const appContextValue = { langGeniusVersionInfo: { current_env: 'DEVELOPMENT', }, - } as unknown as AppContextValue) + } as unknown as AppContextValue + mockAppContextState.current = appContextValue + mockUseAppContext.mockReturnValue(appContextValue) render() expect( diff --git a/web/app/components/header/env-nav/index.tsx b/web/app/components/header/env-nav/index.tsx index 6323f3428e9..a27daa7638a 100644 --- a/web/app/components/header/env-nav/index.tsx +++ b/web/app/components/header/env-nav/index.tsx @@ -1,9 +1,10 @@ 'use client' +import { useAtomValue } from 'jotai' import { useTranslation } from 'react-i18next' import { TerminalSquare } from '@/app/components/base/icons/src/vender/solid/development' import { Beaker02 } from '@/app/components/base/icons/src/vender/solid/education' -import { useAppContext } from '@/context/app-context' +import { langGeniusVersionInfoAtom } from '@/context/app-context-state' const headerEnvClassName: { [k: string]: string } = { DEVELOPMENT: 'bg-[#FEC84B] border-[#FDB022] text-[#93370D]', @@ -12,7 +13,7 @@ const headerEnvClassName: { [k: string]: string } = { const EnvNav = () => { const { t } = useTranslation() - const { langGeniusVersionInfo } = useAppContext() + const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom) const showEnvTag = langGeniusVersionInfo.current_env === 'TESTING' || langGeniusVersionInfo.current_env === 'DEVELOPMENT' if (!showEnvTag) diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index c4175b44418..f9c9f6ca999 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -30,6 +30,9 @@ const { mockIsAgentV2Enabled, mockSwitchWorkspace, mockToastSuccess } = vi.hoist mockToastSuccess: vi.fn(), mockIsAgentV2Enabled: vi.fn(() => true), })) +const mockAppContextState = vi.hoisted(() => ({ + current: undefined as AppContextValue | undefined, +})) vi.mock('@/features/agent-v2/feature-flag', () => ({ isAgentV2Enabled: () => mockIsAgentV2Enabled(), @@ -40,6 +43,16 @@ vi.mock('@/context/app-context', () => ({ useSelector: vi.fn(), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current ?? {}) +}) + +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(), })) @@ -240,6 +253,7 @@ const renderMainNav = ( const queryClient = createTestQueryClient() const getMockAppContext = useAppContext as Mock const currentAppContext = getMockAppContext() as AppContextValue + mockAppContextState.current = currentAppContext queryClient.setQueryData(consoleQuery.workspaces.current.post.queryKey(), currentAppContext.currentWorkspace) queryClient.setQueryData(consoleQuery.workspaces.get.queryKey(), { workspaces: mockWorkspaces }) const resolvedSystemFeatures = { @@ -287,6 +301,7 @@ describe('MainNav', () => { refresh: vi.fn(), }) ;(useAppContext as Mock).mockReturnValue(appContextValue) + mockAppContextState.current = appContextValue ;(useAppContextSelector as Mock).mockImplementation((selector: (state: AppContextValue) => unknown) => selector((useAppContext as Mock)() as AppContextValue)) ;(useProviderContext as Mock).mockReturnValue({ enableBilling: true,