refactor(web): migrate account settings app context consumers (#38544)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Stephen Zhou 2026-07-08 16:15:54 +08:00 committed by GitHub
parent 5ffd4345dc
commit dce45ef6ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 407 additions and 133 deletions

View File

@ -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

View File

@ -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<typeof import('@/service/use-common')>(),
useLogout: () => ({
mutateAsync: mockLogout,
}),

View File

@ -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<typeof actual.useSetAtom>[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]}`)
},
}
}

View File

@ -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) {
}}
/>
<div className="mt-3 flex w-full flex-col gap-2">
<Button className="w-full" disabled={userInputEmail !== userProfile.email || isSendingEmail} loading={isSendingEmail} variant="primary" onClick={handleConfirm}>{t('account.sendVerificationButton', { ns: 'common' })}</Button>
<Button className="w-full" disabled={userInputEmail !== userProfileEmail || isSendingEmail} loading={isSendingEmail} variant="primary" onClick={handleConfirm}>{t('account.sendVerificationButton', { ns: 'common' })}</Button>
<Button className="w-full" onClick={props.onCancel}>{t('operation.cancel', { ns: 'common' })}</Button>
</div>
</>

View File

@ -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()

View File

@ -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<typeof import('@/service/use-common')>(),
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: {

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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',

View File

@ -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<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const workspacePermissionKeys = useAppContextWithSelector(s => s.workspacePermissionKeys)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const canManage = hasPermission(workspacePermissionKeys, 'workspace.role.manage')
const ruleCount = totalCount ?? rules.length

View File

@ -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: {

View File

@ -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<ApiBasedExtensionDialogState>(null)

View File

@ -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)

View File

@ -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<AppContextValue>,
}))
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>): Role => ({
...overrides,
})
const setAppContextValue = (value: AppContextValue) => {
mockAppContextState.current = value
vi.mocked(useAppContext).mockReturnValue(value)
}
vi.mock('../edit-workspace-modal', () => ({
default: ({ onCancel }: { onCancel: () => void }) => (
<div>
@ -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,

View File

@ -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', () => {

View File

@ -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<AppContextValue>,
}))
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', () => {

View File

@ -10,10 +10,21 @@ import EditWorkspaceModal from '../index'
const toastMocks = vi.hoisted(() => ({
mockNotify: vi.fn(),
}))
const mockAppContextState = vi.hoisted(() => ({
current: {} as Partial<AppContextValue>,
}))
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(() => {

View File

@ -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<string>(currentWorkspace.name)
const [isSubmitting, setIsSubmitting] = useState(false)
const inputId = useId()
@ -82,7 +84,6 @@ const EditWorkspaceModal = ({ onCancel }: IEditWorkspaceModalProps) => {
</label>
<Input
id={inputId}
autoFocus
value={name}
placeholder={t('account.workspaceNamePlaceholder', { ns: 'common' })}
onChange={(e) => {

View File

@ -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}

View File

@ -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 <Loading />

View File

@ -11,8 +11,19 @@ import TransferOwnershipModal from '../index'
const toastMocks = vi.hoisted(() => ({
mockNotify: vi.fn(),
}))
const mockAppContextState = vi.hoisted(() => ({
current: {} as Partial<AppContextValue>,
}))
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: [] },

View File

@ -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>(STEP.start)
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
const userProfile = useAtomValue(userProfileAtom)
const [step, setStep] = useState<Step>(STEP.start)
const [code, setCode] = useState<string>('')
const [time, setTime] = useState<number>(0)
const [stepToken, setStepToken] = useState<string>('')
@ -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)

View File

@ -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 },

View File

@ -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 }),

View File

@ -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<ProviderAddedCardProps> = ({
}))
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()

View File

@ -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()

View File

@ -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<ModelListProps> = ({
}) => {
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<ModelListProps> = ({
{t('modelProvider.modelsNum', { ns: 'common', num: models.length })}
<span className="mr-0.5 i-ri-arrow-right-s-line size-4 rotate-90" />
</span>
<span
className="hidden h-6 cursor-pointer items-center rounded-lg bg-state-base-hover pr-1.5 pl-1 system-xs-medium text-text-tertiary group-hover:inline-flex"
<button
type="button"
className="hidden h-6 cursor-pointer items-center rounded-lg border-none bg-state-base-hover pr-1.5 pl-1 system-xs-medium text-text-tertiary group-hover:inline-flex"
onClick={() => onCollapse()}
>
{t('modelProvider.modelsNum', { ns: 'common', num: models.length })}
<span className="mr-0.5 i-ri-arrow-right-s-line size-4 rotate-90" />
</span>
</button>
</span>
{
isConfigurable && canConfigureModels && (

View File

@ -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: [],

View File

@ -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<SystemModelSelectorProps> = ({
onOpenMarketplace,
}) => {
const { t } = useTranslation()
const { workspacePermissionKeys } = useAppContext()
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const { textGenerationModelList } = useProviderContext()
const canManageSystemDefaultModel = hasPermission(workspacePermissionKeys, 'plugin.model_config')
const updateModelList = useUpdateModelList()

View File

@ -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(),

View File

@ -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<ModalState>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const workspacePermissionKeys = useAppContextWithSelector(s => s.workspacePermissionKeys)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const language = useMemo(() => getAccessControlTemplateLanguage(locale), [locale])

View File

@ -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,

View File

@ -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])

View File

@ -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,
}))

View File

@ -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)

View File

@ -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,

View File

@ -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<AppContextValue>,
}))
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(<EnvNav />)
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(<EnvNav />)
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(<EnvNav />)
expect(

View File

@ -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)

View File

@ -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,