mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 02:18:08 +08:00
fix(web): avoid quota panel flicker on account-setting tab switch
- remove mount-time workspace invalidate in model provider page - read quota with useCurrentWorkspace and keep loading only for initial empty fetch - reuse existing useSystemFeaturesQuery for marketplace and trial models - update model provider and quota panel tests for new query/loading behavior
This commit is contained in:
@ -7,16 +7,7 @@ import {
|
||||
} from './declarations'
|
||||
import ModelProviderPage from './index'
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
mutateCurrentWorkspace: vi.fn(),
|
||||
isValidatingCurrentWorkspace: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockGlobalState = {
|
||||
systemFeatures: { enable_marketplace: true },
|
||||
}
|
||||
let mockEnableMarketplace = true
|
||||
|
||||
const mockQuotaConfig = {
|
||||
quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
@ -28,7 +19,11 @@ const mockQuotaConfig = {
|
||||
}
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (s: { systemFeatures: { enable_marketplace: boolean } }) => unknown) => selector(mockGlobalState),
|
||||
useSystemFeaturesQuery: () => ({
|
||||
data: {
|
||||
enable_marketplace: mockEnableMarketplace,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockProviders = [
|
||||
@ -92,7 +87,7 @@ describe('ModelProviderPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
mockGlobalState.systemFeatures.enable_marketplace = true
|
||||
mockEnableMarketplace = true
|
||||
Object.keys(mockDefaultModels).forEach((key) => {
|
||||
mockDefaultModels[key] = { data: null, isLoading: false }
|
||||
})
|
||||
@ -153,7 +148,7 @@ describe('ModelProviderPage', () => {
|
||||
})
|
||||
|
||||
it('should hide marketplace section when marketplace feature is disabled', () => {
|
||||
mockGlobalState.systemFeatures.enable_marketplace = false
|
||||
mockEnableMarketplace = false
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
|
||||
@ -2,11 +2,10 @@ import type {
|
||||
ModelProvider,
|
||||
} from './declarations'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useSystemFeaturesQuery } from '@/context/global-public-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import {
|
||||
@ -32,14 +31,14 @@ const FixedModelProvider = ['langgenius/openai/openai', 'langgenius/anthropic/an
|
||||
const ModelProviderPage = ({ searchText }: Props) => {
|
||||
const debouncedSearchText = useDebounce(searchText, { wait: 500 })
|
||||
const { t } = useTranslation()
|
||||
const { mutateCurrentWorkspace, isValidatingCurrentWorkspace } = useAppContext()
|
||||
const { data: textGenerationDefaultModel, isLoading: isTextGenerationDefaultModelLoading } = useDefaultModel(ModelTypeEnum.textGeneration)
|
||||
const { data: embeddingsDefaultModel, isLoading: isEmbeddingsDefaultModelLoading } = useDefaultModel(ModelTypeEnum.textEmbedding)
|
||||
const { data: rerankDefaultModel, isLoading: isRerankDefaultModelLoading } = useDefaultModel(ModelTypeEnum.rerank)
|
||||
const { data: speech2textDefaultModel, isLoading: isSpeech2textDefaultModelLoading } = useDefaultModel(ModelTypeEnum.speech2text)
|
||||
const { data: ttsDefaultModel, isLoading: isTTSDefaultModelLoading } = useDefaultModel(ModelTypeEnum.tts)
|
||||
const { modelProviders: providers } = useProviderContext()
|
||||
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { data: systemFeatures } = useSystemFeaturesQuery()
|
||||
const enableMarketplace = systemFeatures?.enable_marketplace ?? false
|
||||
const isDefaultModelLoading = isTextGenerationDefaultModelLoading
|
||||
|| isEmbeddingsDefaultModelLoading
|
||||
|| isRerankDefaultModelLoading
|
||||
@ -109,10 +108,6 @@ const ModelProviderPage = ({ searchText }: Props) => {
|
||||
return [filteredConfiguredProviders, filteredNotConfiguredProviders]
|
||||
}, [configuredProviders, debouncedSearchText, notConfiguredProviders])
|
||||
|
||||
useEffect(() => {
|
||||
mutateCurrentWorkspace()
|
||||
}, [mutateCurrentWorkspace])
|
||||
|
||||
return (
|
||||
<div className="relative -mt-2 pt-1">
|
||||
<div className={cn('mb-2 flex items-center')}>
|
||||
@ -140,7 +135,7 @@ const ModelProviderPage = ({ searchText }: Props) => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{IS_CLOUD_EDITION && <QuotaPanel providers={providers} isLoading={isValidatingCurrentWorkspace} />}
|
||||
{IS_CLOUD_EDITION && <QuotaPanel providers={providers} />}
|
||||
{!filteredConfiguredProviders?.length && (
|
||||
<div className="mb-2 rounded-[10px] bg-workflow-process-bg p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur">
|
||||
@ -175,7 +170,7 @@ const ModelProviderPage = ({ searchText }: Props) => {
|
||||
</>
|
||||
)}
|
||||
{
|
||||
enable_marketplace && (
|
||||
enableMarketplace && (
|
||||
<InstallFromMarketplace
|
||||
providers={providers}
|
||||
searchText={searchText}
|
||||
|
||||
@ -2,11 +2,16 @@ import type { ModelProvider } from '../declarations'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import QuotaPanel from './quota-panel'
|
||||
|
||||
let mockWorkspace = {
|
||||
let mockWorkspaceData: {
|
||||
trial_credits: number
|
||||
trial_credits_used: number
|
||||
next_credit_reset_date: string
|
||||
} | undefined = {
|
||||
trial_credits: 100,
|
||||
trial_credits_used: 30,
|
||||
next_credit_reset_date: '2024-12-31',
|
||||
}
|
||||
let mockWorkspaceIsPending = false
|
||||
let mockTrialModels: string[] = ['langgenius/openai/openai']
|
||||
let mockPlugins = [{
|
||||
plugin_id: 'langgenius/openai',
|
||||
@ -25,15 +30,16 @@ vi.mock('@/app/components/base/icons/src/public/llm', () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
currentWorkspace: mockWorkspace,
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useCurrentWorkspace: () => ({
|
||||
data: mockWorkspaceData,
|
||||
isPending: mockWorkspaceIsPending,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: { trial_models: string[] } }) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
useSystemFeaturesQuery: () => ({
|
||||
data: {
|
||||
trial_models: mockTrialModels,
|
||||
},
|
||||
}),
|
||||
@ -71,22 +77,21 @@ describe('QuotaPanel', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkspace = {
|
||||
mockWorkspaceData = {
|
||||
trial_credits: 100,
|
||||
trial_credits_used: 30,
|
||||
next_credit_reset_date: '2024-12-31',
|
||||
}
|
||||
mockWorkspaceIsPending = false
|
||||
mockTrialModels = ['langgenius/openai/openai']
|
||||
mockPlugins = [{ plugin_id: 'langgenius/openai', latest_package_identifier: 'openai@1.0.0' }]
|
||||
})
|
||||
|
||||
it('should render loading state', () => {
|
||||
render(
|
||||
<QuotaPanel
|
||||
providers={mockProviders}
|
||||
isLoading
|
||||
/>,
|
||||
)
|
||||
mockWorkspaceData = undefined
|
||||
mockWorkspaceIsPending = true
|
||||
|
||||
render(<QuotaPanel providers={mockProviders} />)
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -102,8 +107,17 @@ describe('QuotaPanel', () => {
|
||||
expect(screen.getByText(/modelProvider\.resetDate/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep quota content during background refetch when cached workspace exists', () => {
|
||||
mockWorkspaceIsPending = true
|
||||
|
||||
render(<QuotaPanel providers={mockProviders} />)
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('70')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should floor credits at zero when usage is higher than quota', () => {
|
||||
mockWorkspace = {
|
||||
mockWorkspaceData = {
|
||||
trial_credits: 10,
|
||||
trial_credits_used: 999,
|
||||
next_credit_reset_date: '',
|
||||
|
||||
@ -9,9 +9,9 @@ import { AnthropicShortLight, Deepseek, Gemini, Grok, OpenaiSmall, Tongyi } from
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useSystemFeaturesQuery } from '@/context/global-public-context'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import { useCurrentWorkspace } from '@/service/use-common'
|
||||
import { ModelProviderQuotaGetPaid } from '@/types/model-provider'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
@ -48,16 +48,16 @@ const providerKeyToPluginId: Record<ModelProviderQuotaGetPaid, string> = {
|
||||
|
||||
type QuotaPanelProps = {
|
||||
providers: ModelProvider[]
|
||||
isLoading?: boolean
|
||||
}
|
||||
const QuotaPanel: FC<QuotaPanelProps> = ({
|
||||
providers,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { currentWorkspace } = useAppContext()
|
||||
const { trial_models } = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const credits = Math.max((currentWorkspace.trial_credits - currentWorkspace.trial_credits_used) || 0, 0)
|
||||
const { data: currentWorkspace, isPending: isPendingWorkspace } = useCurrentWorkspace()
|
||||
const { data: systemFeatures } = useSystemFeaturesQuery()
|
||||
const trialModels = systemFeatures?.trial_models ?? []
|
||||
const credits = Math.max(((currentWorkspace?.trial_credits ?? 0) - (currentWorkspace?.trial_credits_used ?? 0)) || 0, 0)
|
||||
const isLoading = isPendingWorkspace && !currentWorkspace
|
||||
const providerMap = useMemo(() => new Map(
|
||||
providers.map(p => [p.provider, p.preferred_provider_type]),
|
||||
), [providers])
|
||||
@ -110,13 +110,13 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
|
||||
<div className={cn('my-2 min-w-[72px] shrink-0 rounded-xl border-[0.5px] pb-2.5 pl-4 pr-2.5 pt-3 shadow-xs', credits <= 0 ? 'border-state-destructive-border hover:bg-state-destructive-hover' : 'border-components-panel-border bg-third-party-model-bg-default')}>
|
||||
<div className="system-xs-medium-uppercase mb-2 flex h-4 items-center text-text-tertiary">
|
||||
{t('modelProvider.quota', { ns: 'common' })}
|
||||
<Tooltip popupContent={t('modelProvider.card.tip', { ns: 'common', modelNames: trial_models.map(key => modelNameMap[key as keyof typeof modelNameMap]).filter(Boolean).join(', ') })} />
|
||||
<Tooltip popupContent={t('modelProvider.card.tip', { ns: 'common', modelNames: trialModels.map(key => modelNameMap[key as keyof typeof modelNameMap]).filter(Boolean).join(', ') })} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 text-xs text-text-tertiary">
|
||||
<span className="system-md-semibold-uppercase mr-0.5 text-text-secondary">{formatNumber(credits)}</span>
|
||||
<span>{t('modelProvider.credits', { ns: 'common' })}</span>
|
||||
{currentWorkspace.next_credit_reset_date
|
||||
{currentWorkspace?.next_credit_reset_date
|
||||
? (
|
||||
<>
|
||||
<span>·</span>
|
||||
@ -132,7 +132,7 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
|
||||
: null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{allProviders.filter(({ key }) => trial_models.includes(key)).map(({ key, Icon }) => {
|
||||
{allProviders.filter(({ key }) => trialModels.includes(key)).map(({ key, Icon }) => {
|
||||
const providerType = providerMap.get(key)
|
||||
const isConfigured = (installedProvidersMap.get(key)?.length ?? 0) > 0 // means the provider is configured API key
|
||||
const getTooltipKey = () => {
|
||||
|
||||
Reference in New Issue
Block a user