From 5a210cf03e7b67838b689dccd631dd59f57edd06 Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Wed, 8 Jul 2026 15:02:47 +0800 Subject: [PATCH] refactor(web): migrate billing app context consumers (#38541) --- .../billing/billing-integration.test.tsx | 10 +++++++ .../billing/cloud-plan-payment-flow.test.tsx | 10 +++++++ .../education-verification-flow.test.tsx | 10 +++++++ .../billing/pricing-modal-flow.test.tsx | 10 +++++++ .../billing/self-hosted-plan-flow.test.tsx | 10 +++++++ web/__tests__/utils/mock-app-context-state.ts | 19 ++++++++++-- .../__tests__/index.spec.tsx | 15 +++++++++- .../billing/apps-full-in-dialog/index.tsx | 8 +++-- .../billing-page/__tests__/index.spec.tsx | 13 +++++++++ .../components/billing/billing-page/index.tsx | 6 ++-- .../billing/hooks/use-education-discount.ts | 5 ++-- .../billing/plan/__tests__/index.spec.tsx | 29 +++++++++++++++---- web/app/components/billing/plan/index.tsx | 8 +++-- .../billing/pricing/__tests__/dialog.spec.tsx | 18 +++++++++--- .../billing/pricing/__tests__/index.spec.tsx | 26 ++++++++++++----- web/app/components/billing/pricing/index.tsx | 5 ++-- .../cloud-plan-item/__tests__/index.spec.tsx | 29 +++++++++++++++---- .../pricing/plans/cloud-plan-item/index.tsx | 5 ++-- .../__tests__/index.spec.tsx | 21 ++++++++++++-- .../plans/self-hosted-plan-item/index.tsx | 5 ++-- web/context/app-context-state.ts | 12 ++++++++ 21 files changed, 230 insertions(+), 44 deletions(-) diff --git a/web/__tests__/billing/billing-integration.test.tsx b/web/__tests__/billing/billing-integration.test.tsx index d173392a420..5820e552b95 100644 --- a/web/__tests__/billing/billing-integration.test.tsx +++ b/web/__tests__/billing/billing-integration.test.tsx @@ -36,6 +36,16 @@ vi.mock('@/context/app-context', () => ({ useAppContext: () => mockAppCtx, })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => mockAppCtx) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/context/modal-context', () => ({ useModalContext: () => ({ setShowPricingModal: mockSetShowPricingModal, diff --git a/web/__tests__/billing/cloud-plan-payment-flow.test.tsx b/web/__tests__/billing/cloud-plan-payment-flow.test.tsx index d9e8d1d874a..e5e9ec4ed4e 100644 --- a/web/__tests__/billing/cloud-plan-payment-flow.test.tsx +++ b/web/__tests__/billing/cloud-plan-payment-flow.test.tsx @@ -28,6 +28,16 @@ vi.mock('@/context/app-context', () => ({ useAppContext: () => mockAppCtx, })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => mockAppCtx) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/context/i18n', () => ({ useGetLanguage: () => 'en-US', })) diff --git a/web/__tests__/billing/education-verification-flow.test.tsx b/web/__tests__/billing/education-verification-flow.test.tsx index 1b010964ce2..381bd37dafb 100644 --- a/web/__tests__/billing/education-verification-flow.test.tsx +++ b/web/__tests__/billing/education-verification-flow.test.tsx @@ -41,6 +41,16 @@ vi.mock('@/context/app-context', () => ({ useAppContext: () => mockAppCtx, })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => mockAppCtx) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/context/modal-context', () => ({ useModalContext: () => ({ setShowPricingModal: mockSetShowPricingModal, diff --git a/web/__tests__/billing/pricing-modal-flow.test.tsx b/web/__tests__/billing/pricing-modal-flow.test.tsx index dd1e710b1d6..2298105c651 100644 --- a/web/__tests__/billing/pricing-modal-flow.test.tsx +++ b/web/__tests__/billing/pricing-modal-flow.test.tsx @@ -28,6 +28,16 @@ vi.mock('@/context/app-context', () => ({ useAppContext: () => mockAppCtx, })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => mockAppCtx) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/context/i18n', () => ({ useGetLanguage: () => 'en-US', useGetPricingPageLanguage: () => 'en', diff --git a/web/__tests__/billing/self-hosted-plan-flow.test.tsx b/web/__tests__/billing/self-hosted-plan-flow.test.tsx index 08a3d0fed54..6fc2626ad63 100644 --- a/web/__tests__/billing/self-hosted-plan-flow.test.tsx +++ b/web/__tests__/billing/self-hosted-plan-flow.test.tsx @@ -24,6 +24,16 @@ vi.mock('@/context/app-context', () => ({ useAppContext: () => mockAppCtx, })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => mockAppCtx) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/context/i18n', () => ({ useGetLanguage: () => 'en-US', })) diff --git a/web/__tests__/utils/mock-app-context-state.ts b/web/__tests__/utils/mock-app-context-state.ts index 0c546de1471..5be87eee447 100644 --- a/web/__tests__/utils/mock-app-context-state.ts +++ b/web/__tests__/utils/mock-app-context-state.ts @@ -7,8 +7,8 @@ export type AppContextStateMockState = { id?: string name?: string email?: string - avatar?: string - avatar_url?: string + avatar?: string | null + avatar_url?: string | null is_password_set?: boolean } | null currentWorkspace?: { @@ -27,13 +27,16 @@ export type AppContextStateMockState = { type AppContextStateAtomKind = | 'userProfile' | 'userProfileId' + | 'userProfileEmail' | 'currentWorkspace' | 'currentWorkspaceId' | 'workspaceRoleFlags' + | 'isCurrentWorkspaceManager' | 'currentWorkspaceLoading' | 'workspacePermissionKeys' | 'workspacePermissionKeysLoading' | 'langGeniusVersionInfo' + | 'langGeniusCurrentVersion' type AppContextStateMockAtom = { [APP_CONTEXT_STATE_ATOM_KIND]: AppContextStateAtomKind @@ -101,13 +104,16 @@ export const createAppContextStateAtomMock = async ( ...actual, userProfileAtom: createMockAtom('userProfile'), userProfileIdAtom: createMockAtom('userProfileId'), + userProfileEmailAtom: createMockAtom('userProfileEmail'), currentWorkspaceAtom: createMockAtom('currentWorkspace'), currentWorkspaceIdAtom: createMockAtom('currentWorkspaceId'), workspaceRoleFlagsAtom: createMockAtom('workspaceRoleFlags'), + isCurrentWorkspaceManagerAtom: createMockAtom('isCurrentWorkspaceManager'), currentWorkspaceLoadingAtom: createMockAtom('currentWorkspaceLoading'), workspacePermissionKeysAtom: createMockAtom('workspacePermissionKeys'), workspacePermissionKeysLoadingAtom: createMockAtom('workspacePermissionKeysLoading'), langGeniusVersionInfoAtom: createMockAtom('langGeniusVersionInfo'), + langGeniusCurrentVersionAtom: createMockAtom('langGeniusCurrentVersion'), } } @@ -135,6 +141,9 @@ export const createAppContextStateJotaiMock = async ( if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'userProfileId') return userProfile.id + if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'userProfileEmail') + return userProfile.email + if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'currentWorkspace') return currentWorkspace @@ -150,6 +159,9 @@ export const createAppContextStateJotaiMock = async ( } } + if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'isCurrentWorkspaceManager') + return state.isCurrentWorkspaceManager ?? false + if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'currentWorkspaceLoading') return state.isLoadingCurrentWorkspace ?? false @@ -162,6 +174,9 @@ export const createAppContextStateJotaiMock = async ( if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'langGeniusVersionInfo') return state.langGeniusVersionInfo ?? defaultLangGeniusVersionInfo + if (atom[APP_CONTEXT_STATE_ATOM_KIND] === 'langGeniusCurrentVersion') + return (state.langGeniusVersionInfo ?? defaultLangGeniusVersionInfo).current_version + throw new Error(`Unsupported app context state atom: ${atom[APP_CONTEXT_STATE_ATOM_KIND]}`) }, } diff --git a/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx b/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx index ca0a355c547..72a35eb6843 100644 --- a/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx +++ b/web/app/components/billing/apps-full-in-dialog/__tests__/index.spec.tsx @@ -11,6 +11,8 @@ import { useAppContext } from '@/context/app-context' import { baseProviderContextValue, useProviderContext } from '@/context/provider-context' import AppsFull from '../index' +let mockAppContextState: AppContextValue + vi.mock('@/config', async (importOriginal) => { const actual = await importOriginal() return { @@ -23,6 +25,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) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/context/provider-context', async (importOriginal) => { const actual = await importOriginal() return { @@ -123,7 +135,8 @@ describe('AppsFull', () => { beforeEach(() => { vi.clearAllMocks() ;(useProviderContext as Mock).mockReturnValue(buildProviderContext()) - ;(useAppContext as Mock).mockReturnValue(buildAppContext()) + mockAppContextState = buildAppContext() + ;(useAppContext as Mock).mockReturnValue(mockAppContextState) ;(mailToSupport as Mock).mockReturnValue('mailto:support@example.com') }) diff --git a/web/app/components/billing/apps-full-in-dialog/index.tsx b/web/app/components/billing/apps-full-in-dialog/index.tsx index 486b2036317..51e76325a3d 100644 --- a/web/app/components/billing/apps-full-in-dialog/index.tsx +++ b/web/app/components/billing/apps-full-in-dialog/index.tsx @@ -4,11 +4,12 @@ import type { FC } from 'react' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { MeterIndicator, MeterRoot, MeterTrack } from '@langgenius/dify-ui/meter' +import { useAtomValue } from 'jotai' import * as React from 'react' import { useTranslation } from 'react-i18next' import { Plan } from '@/app/components/billing/type' import { mailToSupport } from '@/app/components/header/utils/util' -import { useAppContext } from '@/context/app-context' +import { langGeniusCurrentVersionAtom, userProfileEmailAtom } from '@/context/app-context-state' import { useProviderContext } from '@/context/provider-context' import UpgradeBtn from '../upgrade-btn' import s from './style.module.css' @@ -19,7 +20,8 @@ const AppsFull: FC<{ loc: string, className?: string }> = ({ }) => { const { t } = useTranslation() const { plan } = useProviderContext() - const { userProfile, langGeniusVersionInfo } = useAppContext() + const userProfileEmail = useAtomValue(userProfileEmailAtom) + const currentVersion = useAtomValue(langGeniusCurrentVersionAtom) const isTeam = plan.type === Plan.team const usage = plan.usage.buildApps const total = plan.total.buildApps @@ -54,7 +56,7 @@ const AppsFull: FC<{ loc: string, className?: string }> = ({ )} {plan.type !== Plan.sandbox && plan.type !== Plan.professional && ( diff --git a/web/app/components/billing/billing-page/__tests__/index.spec.tsx b/web/app/components/billing/billing-page/__tests__/index.spec.tsx index 07d6e62131a..25f93ff5c05 100644 --- a/web/app/components/billing/billing-page/__tests__/index.spec.tsx +++ b/web/app/components/billing/billing-page/__tests__/index.spec.tsx @@ -41,6 +41,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, () => ({ + isCurrentWorkspaceManager: isManager, + workspacePermissionKeys, + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/context/provider-context', () => ({ useProviderContext: () => ({ enableBilling, diff --git a/web/app/components/billing/billing-page/index.tsx b/web/app/components/billing/billing-page/index.tsx index d518810c668..a4454fbb370 100644 --- a/web/app/components/billing/billing-page/index.tsx +++ b/web/app/components/billing/billing-page/index.tsx @@ -1,8 +1,9 @@ 'use client' import type { FC } from 'react' +import { useAtomValue } from 'jotai' import * as React from 'react' import { useTranslation } from 'react-i18next' -import { useAppContext } from '@/context/app-context' +import { isCurrentWorkspaceManagerAtom, workspacePermissionKeysAtom } from '@/context/app-context-state' import { useProviderContext } from '@/context/provider-context' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import { useBillingUrl } from '@/service/use-billing' @@ -11,7 +12,8 @@ import PlanComp from '../plan' const Billing: FC = () => { const { t } = useTranslation() - const { isCurrentWorkspaceManager, workspacePermissionKeys } = useAppContext() + const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const { enableBilling } = useProviderContext() const canManageBillingSubscription = isCurrentWorkspaceManager && hasPermission(workspacePermissionKeys, BillingPermission.SubscriptionManage) const { data: billingUrl, isFetching, refetch } = useBillingUrl(enableBilling && canManageBillingSubscription) diff --git a/web/app/components/billing/hooks/use-education-discount.ts b/web/app/components/billing/hooks/use-education-discount.ts index 07977bf4f16..317be231003 100644 --- a/web/app/components/billing/hooks/use-education-discount.ts +++ b/web/app/components/billing/hooks/use-education-discount.ts @@ -1,15 +1,16 @@ 'use client' 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 { workspacePermissionKeysAtom } from '@/context/app-context-state' import { fetchSubscriptionUrls } from '@/service/billing' import { BillingPermission, hasPermission } from '@/utils/permission' import { Plan } from '../type' export const useEducationDiscount = () => { const { t } = useTranslation() - const { workspacePermissionKeys } = useAppContext() + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const [isEducationDiscountLoading, setIsEducationDiscountLoading] = useState(false) const canManageBilling = hasPermission(workspacePermissionKeys, BillingPermission.Manage) diff --git a/web/app/components/billing/plan/__tests__/index.spec.tsx b/web/app/components/billing/plan/__tests__/index.spec.tsx index f062acba15e..9e0437ffd7f 100644 --- a/web/app/components/billing/plan/__tests__/index.spec.tsx +++ b/web/app/components/billing/plan/__tests__/index.spec.tsx @@ -21,11 +21,16 @@ vi.mock('@/next/navigation', () => ({ usePathname: () => currentPath, })) -vi.mock('@/config', () => ({ - get IS_CLOUD_EDITION() { - return mockConfig.isCloudEdition - }, -})) +vi.mock('@/config', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + get IS_CLOUD_EDITION() { + return mockConfig.isCloudEdition + }, + } +}) const setShowAccountSettingModalMock = vi.fn() vi.mock('@/context/modal-context', () => ({ @@ -47,6 +52,20 @@ vi.mock('@/context/app-context', () => ({ }), })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => ({ + userProfile: { email: 'user@example.com' }, + isCurrentWorkspaceManager, + workspacePermissionKeys, + })) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('@/app/education-apply/storage', () => ({ useSetEducationVerifying: () => setEducationVerifyingMock, })) diff --git a/web/app/components/billing/plan/index.tsx b/web/app/components/billing/plan/index.tsx index 775f6997f19..089841d0327 100644 --- a/web/app/components/billing/plan/index.tsx +++ b/web/app/components/billing/plan/index.tsx @@ -7,6 +7,7 @@ import { RiGroupLine, } from '@remixicon/react' import { useUnmountedRef } from 'ahooks' +import { useAtomValue } from 'jotai' import * as React from 'react' import { useEffect } from 'react' import { useTranslation } from 'react-i18next' @@ -15,7 +16,7 @@ import UsageInfo from '@/app/components/billing/usage-info' import { useSetEducationVerifying } from '@/app/education-apply/storage' import VerifyStateModal from '@/app/education-apply/verify-state-modal' import { IS_CLOUD_EDITION } from '@/config' -import { useAppContext } from '@/context/app-context' +import { userProfileEmailAtom, workspacePermissionKeysAtom } from '@/context/app-context-state' import { useModalContextSelector } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' import { usePathname, useRouter } from '@/next/navigation' @@ -41,7 +42,8 @@ const PlanComp: FC = ({ const { t } = useTranslation() const router = useRouter() const path = usePathname() - const { userProfile, workspacePermissionKeys } = useAppContext() + const userProfileEmail = useAtomValue(userProfileEmailAtom) + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const { plan, enableEducationPlan, allowRefreshEducationVerify, isEducationAccount } = useProviderContext() const isAboutToExpire = allowRefreshEducationVerify const { @@ -181,7 +183,7 @@ const PlanComp: FC = ({ = {} vi.mock('@langgenius/dify-ui/dialog', () => ({ Dialog: ({ children, onOpenChange }: DialogProps) => { @@ -45,9 +45,19 @@ vi.mock('../footer', () => ({ })) vi.mock('@/context/app-context', () => ({ - useAppContext: vi.fn(), + useAppContext: () => mockAppCtx, })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => mockAppCtx) +}) + +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(), })) @@ -70,10 +80,10 @@ describe('Pricing dialog lifecycle', () => { beforeEach(() => { vi.clearAllMocks() latestOnOpenChange = undefined - ;(useAppContext as Mock).mockReturnValue({ + mockAppCtx = { isCurrentWorkspaceManager: true, workspacePermissionKeys: ['billing.manage'], - }) + } ;(useProviderContext as Mock).mockReturnValue({ plan: { type: Plan.sandbox, diff --git a/web/app/components/billing/pricing/__tests__/index.spec.tsx b/web/app/components/billing/pricing/__tests__/index.spec.tsx index 3a6d81aa0a2..1e5df7614d9 100644 --- a/web/app/components/billing/pricing/__tests__/index.spec.tsx +++ b/web/app/components/billing/pricing/__tests__/index.spec.tsx @@ -2,13 +2,13 @@ import type { Mock } from 'vitest' import type { UsagePlanInfo } from '../../type' import { fireEvent, render, screen } from '@testing-library/react' import * as React from 'react' -import { useAppContext } from '@/context/app-context' import { useGetPricingPageLanguage } from '@/context/i18n' import { useProviderContext } from '@/context/provider-context' import { Plan } from '../../type' import Pricing from '../index' let mockLanguage: string | null = 'en' +let mockAppCtx: Record = {} vi.mock('../plans/self-hosted-plan-item/list', () => ({ default: ({ plan }: { plan: string }) => ( @@ -28,9 +28,19 @@ vi.mock('@/next/link', () => ({ })) vi.mock('@/context/app-context', () => ({ - useAppContext: vi.fn(), + useAppContext: () => mockAppCtx, })) +vi.mock('@/context/app-context-state', async (importOriginal) => { + const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateAtomMock(importOriginal, () => mockAppCtx) +}) + +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(), })) @@ -53,10 +63,10 @@ describe('Pricing', () => { beforeEach(() => { vi.clearAllMocks() mockLanguage = 'en' - ;(useAppContext as Mock).mockReturnValue({ + mockAppCtx = { isCurrentWorkspaceManager: true, workspacePermissionKeys: ['billing.manage'], - }) + } ;(useProviderContext as Mock).mockReturnValue({ plan: { type: Plan.sandbox, @@ -79,10 +89,10 @@ describe('Pricing', () => { }) it('should default to yearly billing for education accounts', () => { - ;(useAppContext as Mock).mockReturnValue({ + mockAppCtx = { isCurrentWorkspaceManager: false, workspacePermissionKeys: ['billing.manage'], - }) + } ;(useProviderContext as Mock).mockReturnValue({ plan: { type: Plan.sandbox, @@ -99,10 +109,10 @@ describe('Pricing', () => { }) it('should not default to yearly billing when billing manage permission is missing', () => { - ;(useAppContext as Mock).mockReturnValue({ + mockAppCtx = { isCurrentWorkspaceManager: true, workspacePermissionKeys: [], - }) + } ;(useProviderContext as Mock).mockReturnValue({ plan: { type: Plan.sandbox, diff --git a/web/app/components/billing/pricing/index.tsx b/web/app/components/billing/pricing/index.tsx index 07dddc408bc..0640d63b92b 100644 --- a/web/app/components/billing/pricing/index.tsx +++ b/web/app/components/billing/pricing/index.tsx @@ -10,9 +10,10 @@ import { ScrollAreaThumb, ScrollAreaViewport, } from '@langgenius/dify-ui/scroll-area' +import { useAtomValue } from 'jotai' import * as React from 'react' import { useState } from 'react' -import { useAppContext } from '@/context/app-context' +import { workspacePermissionKeysAtom } from '@/context/app-context-state' import { useGetPricingPageLanguage } from '@/context/i18n' import { useProviderContext } from '@/context/provider-context' import { BillingPermission, hasPermission } from '@/utils/permission' @@ -32,7 +33,7 @@ const Pricing: FC = ({ onCancel, }) => { const { plan, enableEducationPlan, isEducationAccount } = useProviderContext() - const { workspacePermissionKeys } = useAppContext() + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canManageBilling = hasPermission(workspacePermissionKeys, BillingPermission.Manage) const shouldDefaultToYearly = canManageBilling && enableEducationPlan && isEducationAccount const [selectedPlanRange, setSelectedPlanRange] = React.useState() diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx index c1cf7444d77..05e2e76adc7 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/__tests__/index.spec.tsx @@ -12,10 +12,22 @@ import { Plan } from '../../../../type' import { PlanRange } from '../../../plan-switcher/plan-range-switcher' import CloudPlanItem from '../index' +let mockAppCtx: Record = {} + 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, () => mockAppCtx) +}) + +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(), })) @@ -53,6 +65,11 @@ const mockFetchSubscriptionUrls = fetchSubscriptionUrls as Mock let assignedHref = '' const originalLocation = window.location +const mockAppContext = (state: Record) => { + mockAppCtx = state + mockUseAppContext.mockReturnValue(state) +} + const renderWithToastHost = (ui: React.ReactNode) => { return render( <> @@ -79,7 +96,7 @@ beforeAll(() => { beforeEach(() => { vi.clearAllMocks() toast.dismiss() - mockUseAppContext.mockReturnValue({ + mockAppContext({ isCurrentWorkspaceManager: true, workspacePermissionKeys: [ 'billing.view', @@ -183,7 +200,7 @@ describe('CloudPlanItem', () => { // Payment actions triggered from the CTA describe('Plan purchase flow', () => { it('should show toast when billing manage permission is missing for plan purchase', () => { - mockUseAppContext.mockReturnValue({ + mockAppContext({ isCurrentWorkspaceManager: true, workspacePermissionKeys: ['billing.subscription.manage'], }) @@ -206,7 +223,7 @@ describe('CloudPlanItem', () => { it('should open billing portal when upgrading current paid plan', async () => { const openWindow = vi.fn(async (cb: () => Promise) => await cb()) mockUseAsyncWindowOpen.mockReturnValue(openWindow) - mockUseAppContext.mockReturnValue({ + mockAppContext({ isCurrentWorkspaceManager: false, workspacePermissionKeys: ['billing.subscription.manage'], }) @@ -229,7 +246,7 @@ describe('CloudPlanItem', () => { }) it('should redirect to subscription url when selecting a new paid plan', async () => { - mockUseAppContext.mockReturnValue({ + mockAppContext({ isCurrentWorkspaceManager: false, workspacePermissionKeys: ['billing.manage'], }) @@ -316,7 +333,7 @@ describe('CloudPlanItem', () => { }) it('should show default CTA and hide warning when billing manage permission is missing', () => { - mockUseAppContext.mockReturnValue({ + mockAppContext({ isCurrentWorkspaceManager: true, workspacePermissionKeys: [], }) @@ -340,7 +357,7 @@ describe('CloudPlanItem', () => { }) it('should hide education unsupported warning when billing manage permission is missing', () => { - mockUseAppContext.mockReturnValue({ + mockAppContext({ isCurrentWorkspaceManager: true, workspacePermissionKeys: [], }) diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx index 7da575a02bd..6dd974c12bd 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx @@ -10,10 +10,11 @@ import { DialogTitle, } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' +import { useAtomValue } from 'jotai' import * as React from 'react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { useAppContext } from '@/context/app-context' +import { workspacePermissionKeysAtom } from '@/context/app-context-state' import { useProviderContext } from '@/context/provider-context' import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import { fetchSubscriptionUrls } from '@/service/billing' @@ -56,7 +57,7 @@ const CloudPlanItem: FC = ({ const isCurrent = plan === currentPlan const isCurrentPaidPlan = isCurrent && !isFreePlan const isPlanDisabled = isCurrentPaidPlan ? false : planInfo.level <= ALL_PLANS[currentPlan].level - const { workspacePermissionKeys } = useAppContext() + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canManageBilling = hasPermission(workspacePermissionKeys, BillingPermission.Manage) const canManageBillingSubscription = hasPermission(workspacePermissionKeys, BillingPermission.SubscriptionManage) const { enableEducationPlan, isEducationAccount } = useProviderContext() diff --git a/web/app/components/billing/pricing/plans/self-hosted-plan-item/__tests__/index.spec.tsx b/web/app/components/billing/pricing/plans/self-hosted-plan-item/__tests__/index.spec.tsx index c2923f98071..11c57d8ab67 100644 --- a/web/app/components/billing/pricing/plans/self-hosted-plan-item/__tests__/index.spec.tsx +++ b/web/app/components/billing/pricing/plans/self-hosted-plan-item/__tests__/index.spec.tsx @@ -7,6 +7,8 @@ import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from ' import { SelfHostedPlan } from '../../../../type' import SelfHostedPlanItem from '../index' +let mockAppCtx: Record = {} + vi.mock('../list', () => ({ default: ({ plan }: { plan: string }) => (
@@ -20,6 +22,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, () => mockAppCtx) +}) + +vi.mock('jotai', async (importOriginal) => { + const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state') + return createAppContextStateJotaiMock(importOriginal) +}) + vi.mock('../../../assets', () => ({ Community: () =>
Community Icon
, Premium: () =>
Premium Icon
, @@ -33,6 +45,11 @@ const mockUseAppContext = useAppContext as Mock let assignedHref = '' const originalLocation = window.location +const mockAppContext = (state: Record) => { + mockAppCtx = state + mockUseAppContext.mockReturnValue(state) +} + const renderWithToastHost = (ui: React.ReactNode) => { return render( <> @@ -59,7 +76,7 @@ beforeAll(() => { beforeEach(() => { vi.clearAllMocks() toast.dismiss() - mockUseAppContext.mockReturnValue({ + mockAppContext({ isCurrentWorkspaceManager: false, workspacePermissionKeys: ['billing.manage'], }) @@ -94,7 +111,7 @@ describe('SelfHostedPlanItem', () => { describe('CTA interactions', () => { it('should show toast when billing manage permission is missing', () => { - mockUseAppContext.mockReturnValue({ + mockAppContext({ isCurrentWorkspaceManager: true, workspacePermissionKeys: [], }) diff --git a/web/app/components/billing/pricing/plans/self-hosted-plan-item/index.tsx b/web/app/components/billing/pricing/plans/self-hosted-plan-item/index.tsx index 233f26ceb3f..b80fe3ff52d 100644 --- a/web/app/components/billing/pricing/plans/self-hosted-plan-item/index.tsx +++ b/web/app/components/billing/pricing/plans/self-hosted-plan-item/index.tsx @@ -2,11 +2,12 @@ import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' +import { useAtomValue } from 'jotai' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { Azure, GoogleCloud } from '@/app/components/base/icons/src/public/billing' -import { useAppContext } from '@/context/app-context' +import { workspacePermissionKeysAtom } from '@/context/app-context-state' import { BillingPermission, hasPermission } from '@/utils/permission' import { contactSalesUrl, getStartedWithCommunityUrl, getWithPremiumUrl } from '../../../config' import { SelfHostedPlan } from '../../../type' @@ -52,7 +53,7 @@ const SelfHostedPlanItem: FC = ({ const isFreePlan = plan === SelfHostedPlan.community const isPremiumPlan = plan === SelfHostedPlan.premium const isEnterprisePlan = plan === SelfHostedPlan.enterprise - const { workspacePermissionKeys } = useAppContext() + const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom) const canManageBilling = hasPermission(workspacePermissionKeys, BillingPermission.Manage) const handleGetPayUrl = useCallback(() => { diff --git a/web/context/app-context-state.ts b/web/context/app-context-state.ts index 04676f51099..2ad86521260 100644 --- a/web/context/app-context-state.ts +++ b/web/context/app-context-state.ts @@ -46,6 +46,10 @@ export const userProfileIdAtom = atom((get) => { return get(userProfileAtom).id }) +export const userProfileEmailAtom = atom((get) => { + return get(userProfileAtom).email +}) + const profileMetaAtom = atom((get) => { const accountProfileQuery = get(accountProfileQueryAtom) as SuspenseQueryResult @@ -81,6 +85,10 @@ export const isCurrentWorkspaceOwnerAtom = atom((get) => { return get(workspaceRoleFlagsAtom).isCurrentWorkspaceOwner }) +export const isCurrentWorkspaceManagerAtom = atom((get) => { + return get(workspaceRoleFlagsAtom).isCurrentWorkspaceManager +}) + const workspacePermissionKeysQueryAtom = atomWithQuery((get) => { const workspaceId = get(currentWorkspaceIdAtom) @@ -128,6 +136,10 @@ export const langGeniusVersionInfoAtom = atom((get) => { }) }) +export const langGeniusCurrentVersionAtom = atom((get) => { + return get(langGeniusVersionInfoAtom).current_version +}) + export const refreshUserProfileAtom = atom(null, (get) => { const queryClient = get(queryClientAtom) queryClient.invalidateQueries({ queryKey: userProfileQueryOptions().queryKey })