mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 11:04:27 +08:00
refactor(web): retire provider context plan state (#41925)
This commit is contained in:
parent
a9cb17eb6c
commit
8fc11b2927
@ -5133,7 +5133,7 @@
|
||||
},
|
||||
"web/service/use-common.ts": {
|
||||
"@tanstack/query/prefer-query-options": {
|
||||
"count": 12
|
||||
"count": 11
|
||||
}
|
||||
},
|
||||
"web/service/use-datasource.ts": {
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
|
||||
import type { UsagePlanInfo } from '@/app/components/billing/type'
|
||||
import type { ProviderContextState } from '@/context/provider-context'
|
||||
import { merge } from 'es-toolkit/compat'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
|
||||
// Avoid being mocked in tests
|
||||
export const baseProviderContextValue: ProviderContextState = {
|
||||
@ -13,15 +10,12 @@ export const baseProviderContextValue: ProviderContextState = {
|
||||
isLoadingModelProviders: false,
|
||||
isSuccessModelProviders: false,
|
||||
textGenerationModelList: [],
|
||||
supportRetrievalMethods: [],
|
||||
isAPIKeySet: true,
|
||||
plan: defaultPlan,
|
||||
|
||||
enableSkill: false,
|
||||
enableReplaceWebAppLogo: false,
|
||||
modelLoadBalancingEnabled: false,
|
||||
enableEducationPlan: false,
|
||||
webappCopyrightEnabled: false,
|
||||
isAllowTransferWorkspace: false,
|
||||
isAllowPublishAsCustomKnowledgePipelineTemplate: false,
|
||||
humanInputEmailDeliveryEnabled: false,
|
||||
@ -37,43 +31,3 @@ export const createMockProviderContextValue = (
|
||||
refreshModelProviders: merged.refreshModelProviders ?? noop,
|
||||
}
|
||||
}
|
||||
|
||||
export const createMockPlan = (plan: CloudPlan): ProviderContextState =>
|
||||
createMockProviderContextValue({
|
||||
plan: merge({}, defaultPlan, {
|
||||
type: plan,
|
||||
}),
|
||||
})
|
||||
|
||||
export const createMockPlanUsage = (
|
||||
usage: UsagePlanInfo,
|
||||
ctx: Partial<ProviderContextState>,
|
||||
): ProviderContextState =>
|
||||
createMockProviderContextValue({
|
||||
...ctx,
|
||||
plan: merge(ctx.plan, {
|
||||
usage,
|
||||
}),
|
||||
})
|
||||
|
||||
export const createMockPlanTotal = (
|
||||
total: UsagePlanInfo,
|
||||
ctx: Partial<ProviderContextState>,
|
||||
): ProviderContextState =>
|
||||
createMockProviderContextValue({
|
||||
...ctx,
|
||||
plan: merge(ctx.plan, {
|
||||
total,
|
||||
}),
|
||||
})
|
||||
|
||||
export const createMockPlanReset = (
|
||||
reset: Partial<ProviderContextState['plan']['reset']>,
|
||||
ctx: Partial<ProviderContextState>,
|
||||
): ProviderContextState =>
|
||||
createMockProviderContextValue({
|
||||
...ctx,
|
||||
plan: merge(ctx?.plan, {
|
||||
reset,
|
||||
}),
|
||||
})
|
||||
|
||||
@ -1,15 +1,19 @@
|
||||
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
|
||||
import type {
|
||||
GetFeaturesResponse,
|
||||
GetFeaturesVectorSpaceResponse,
|
||||
} from '@dify/contracts/api/console/features/types.gen'
|
||||
import type { RenderOptions } from '@testing-library/react'
|
||||
import type { ReactElement } from 'react'
|
||||
import type { UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/type'
|
||||
import type { DeepPartial } from '@/test/console/system-features'
|
||||
import { screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import dayjs from 'dayjs'
|
||||
import * as React from 'react'
|
||||
import AnnotationFull from '@/app/components/billing/annotation-full'
|
||||
import AnnotationFullModal from '@/app/components/billing/annotation-full/modal'
|
||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||
import Billing from '@/app/components/billing/billing-page'
|
||||
import { defaultPlan, NUM_INFINITE } from '@/app/components/billing/config'
|
||||
import { NUM_INFINITE } from '@/app/components/billing/config'
|
||||
import PlanComp from '@/app/components/billing/plan'
|
||||
import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal'
|
||||
import PriorityLabel from '@/app/components/billing/priority-label'
|
||||
@ -24,16 +28,15 @@ import {
|
||||
} from '@/test/console/query-data'
|
||||
import { render as renderWithConsoleState } from '@/test/console/render'
|
||||
|
||||
let mockProviderCtx: Record<string, unknown> = {}
|
||||
let mockFeatures: DeepPartial<GetFeaturesResponse> = {}
|
||||
let mockVectorSpace: GetFeaturesVectorSpaceResponse = { size: 0, limit: 50, usage_unknown: false }
|
||||
let mockConsoleState: Record<string, unknown> = {}
|
||||
let mockEducationStatus = { is_student: false, allow_refresh: false, expire_at: null }
|
||||
|
||||
const render = (ui: ReactElement, options: RenderOptions = {}, vectorSpaceUsageUnknown = false) => {
|
||||
const queryClient = createConsoleQueryClient()
|
||||
const plan = mockProviderCtx.plan as ReturnType<typeof createPlanData>
|
||||
queryClient.setQueryData(consoleQuery.features.vectorSpace.get.queryOptions().queryKey, {
|
||||
size: plan.usage.vectorSpace,
|
||||
limit: plan.total.vectorSpace,
|
||||
...mockVectorSpace,
|
||||
usage_unknown: vectorSpaceUsageUnknown,
|
||||
})
|
||||
queryClient.setQueryData(consoleQuery.billing.invoices.get.queryOptions().queryKey, {
|
||||
@ -44,10 +47,7 @@ const render = (ui: ReactElement, options: RenderOptions = {}, vectorSpaceUsageU
|
||||
accountProfile: mockConsoleState.userProfile as { email?: string },
|
||||
accountProfileMeta: { currentVersion: '1.0.0' },
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
features: {
|
||||
billing: { subscription: { plan: plan.type } },
|
||||
apps: { size: plan.usage.buildApps, limit: plan.total.buildApps },
|
||||
},
|
||||
features: mockFeatures,
|
||||
queryClient,
|
||||
})
|
||||
return renderWithConsoleState(ui, { ...options, wrapper })
|
||||
@ -55,10 +55,6 @@ const render = (ui: ReactElement, options: RenderOptions = {}, vectorSpaceUsageU
|
||||
|
||||
const mockSetShowPricingModal = vi.fn()
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockProviderCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/workspace-state', async () => {
|
||||
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
return createWorkspaceStateModuleMock(() => mockConsoleState)
|
||||
@ -87,25 +83,13 @@ vi.mock('@/app/components/header/utils/util', () => ({
|
||||
}))
|
||||
|
||||
// ─── Test data factories ────────────────────────────────────────────────────
|
||||
type PlanOverrides = {
|
||||
type?: CloudPlan
|
||||
usage?: Partial<UsagePlanInfo>
|
||||
total?: Partial<UsagePlanInfo>
|
||||
reset?: Partial<UsageResetInfo>
|
||||
type BillingOverrides = DeepPartial<GetFeaturesResponse> & {
|
||||
vectorSpace?: Partial<GetFeaturesVectorSpaceResponse>
|
||||
}
|
||||
|
||||
const createPlanData = (overrides: PlanOverrides = {}) => ({
|
||||
...defaultPlan,
|
||||
...overrides,
|
||||
type: overrides.type ?? defaultPlan.type,
|
||||
usage: { ...defaultPlan.usage, ...overrides.usage },
|
||||
total: { ...defaultPlan.total, ...overrides.total },
|
||||
reset: { ...defaultPlan.reset, ...overrides.reset },
|
||||
})
|
||||
|
||||
const setupProviderContext = (
|
||||
planOverrides: PlanOverrides = {},
|
||||
extra: Record<string, unknown> = {},
|
||||
const setupBilling = (
|
||||
{ vectorSpace, ...features }: BillingOverrides = {},
|
||||
education: { enableEducationPlan?: boolean } = {},
|
||||
educationStatus: Partial<typeof mockEducationStatus> = {},
|
||||
) => {
|
||||
mockEducationStatus = {
|
||||
@ -114,11 +98,8 @@ const setupProviderContext = (
|
||||
expire_at: null,
|
||||
...educationStatus,
|
||||
}
|
||||
mockProviderCtx = {
|
||||
plan: createPlanData(planOverrides),
|
||||
enableEducationPlan: false,
|
||||
...extra,
|
||||
}
|
||||
mockFeatures = { ...features, education: { enabled: education.enableEducationPlan ?? false } }
|
||||
mockVectorSpace = { size: 0, limit: 50, usage_unknown: false, ...vectorSpace }
|
||||
}
|
||||
|
||||
const setupConsoleState = (overrides: Record<string, unknown> = {}) => {
|
||||
@ -146,26 +127,15 @@ describe('Billing Page + Plan Integration', () => {
|
||||
// Verify that the billing page renders PlanComp with all 7 usage items
|
||||
describe('Rendering complete plan information', () => {
|
||||
it('should display all 7 usage metrics for sandbox plan', () => {
|
||||
setupProviderContext({
|
||||
type: 'sandbox',
|
||||
usage: {
|
||||
buildApps: 3,
|
||||
teamMembers: 1,
|
||||
documentsUploadQuota: 10,
|
||||
vectorSpace: 20,
|
||||
annotatedResponse: 5,
|
||||
triggerEvents: 1000,
|
||||
apiRateLimit: 2000,
|
||||
},
|
||||
total: {
|
||||
buildApps: 5,
|
||||
teamMembers: 1,
|
||||
documentsUploadQuota: 50,
|
||||
vectorSpace: 50,
|
||||
annotatedResponse: 10,
|
||||
triggerEvents: 3000,
|
||||
apiRateLimit: 5000,
|
||||
},
|
||||
setupBilling({
|
||||
billing: { subscription: { plan: 'sandbox' } },
|
||||
apps: { size: 3, limit: 5 },
|
||||
members: { size: 1, limit: 1 },
|
||||
documents_upload_quota: { size: 10, limit: 50 },
|
||||
annotation_quota_limit: { size: 5, limit: 10 },
|
||||
trigger_event: { usage: 1000, limit: 3000 },
|
||||
api_rate_limit: { usage: 2000, limit: 5000 },
|
||||
vectorSpace: { size: 20, limit: 50 },
|
||||
})
|
||||
|
||||
render(<Billing />)
|
||||
@ -184,10 +154,9 @@ describe('Billing Page + Plan Integration', () => {
|
||||
})
|
||||
|
||||
it('should expose each quota card and its value through stable semantics', () => {
|
||||
setupProviderContext({
|
||||
type: 'sandbox',
|
||||
usage: { teamMembers: 3 },
|
||||
total: { teamMembers: 5 },
|
||||
setupBilling({
|
||||
billing: { subscription: { plan: 'sandbox' } },
|
||||
members: { size: 3, limit: 5 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
@ -202,10 +171,9 @@ describe('Billing Page + Plan Integration', () => {
|
||||
})
|
||||
|
||||
it('should display unknown vector space usage as a placeholder', () => {
|
||||
setupProviderContext({
|
||||
type: 'sandbox',
|
||||
usage: { vectorSpace: 0 },
|
||||
total: { vectorSpace: 50 },
|
||||
setupBilling({
|
||||
billing: { subscription: { plan: 'sandbox' } },
|
||||
vectorSpace: { size: 0, limit: 50 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />, {}, true)
|
||||
@ -217,9 +185,9 @@ describe('Billing Page + Plan Integration', () => {
|
||||
})
|
||||
|
||||
it('should show "unlimited" for infinite quotas (professional API rate limit)', () => {
|
||||
setupProviderContext({
|
||||
type: 'professional',
|
||||
total: { apiRateLimit: NUM_INFINITE },
|
||||
setupBilling({
|
||||
billing: { subscription: { plan: 'professional' } },
|
||||
api_rate_limit: { limit: NUM_INFINITE },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
@ -228,10 +196,9 @@ describe('Billing Page + Plan Integration', () => {
|
||||
})
|
||||
|
||||
it('should display reset days for trigger events when applicable', () => {
|
||||
setupProviderContext({
|
||||
type: 'professional',
|
||||
total: { triggerEvents: 20000 },
|
||||
reset: { triggerEvents: 7 },
|
||||
setupBilling({
|
||||
billing: { subscription: { plan: 'professional' } },
|
||||
trigger_event: { limit: 20000, reset_date: dayjs().add(7, 'day').startOf('day').unix() },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
@ -244,7 +211,7 @@ describe('Billing Page + Plan Integration', () => {
|
||||
// Verify billing URL button visibility and behavior
|
||||
describe('Billing URL button', () => {
|
||||
it('should show billing button to managers without billing permission keys', () => {
|
||||
setupProviderContext({ type: 'sandbox' })
|
||||
setupBilling({ billing: { subscription: { plan: 'sandbox' } } })
|
||||
setupConsoleState({
|
||||
isCurrentWorkspaceManager: true,
|
||||
workspacePermissionKeys: [],
|
||||
@ -257,7 +224,7 @@ describe('Billing Page + Plan Integration', () => {
|
||||
})
|
||||
|
||||
it('should hide billing button from non-manager members', () => {
|
||||
setupProviderContext({ type: 'sandbox' })
|
||||
setupBilling({ billing: { subscription: { plan: 'sandbox' } } })
|
||||
setupConsoleState({
|
||||
isCurrentWorkspaceManager: false,
|
||||
})
|
||||
@ -268,7 +235,7 @@ describe('Billing Page + Plan Integration', () => {
|
||||
})
|
||||
|
||||
it('should show billing button when a manager has no billing permission keys', () => {
|
||||
setupProviderContext({ type: 'sandbox' })
|
||||
setupBilling({ billing: { subscription: { plan: 'sandbox' } } })
|
||||
setupConsoleState({
|
||||
isCurrentWorkspaceManager: true,
|
||||
workspacePermissionKeys: [],
|
||||
@ -292,7 +259,7 @@ describe('Plan Type Display Integration', () => {
|
||||
})
|
||||
|
||||
it('should render sandbox plan with upgrade button (premium badge)', () => {
|
||||
setupProviderContext({ type: 'sandbox' })
|
||||
setupBilling({ billing: { subscription: { plan: 'sandbox' } } })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
@ -303,7 +270,7 @@ describe('Plan Type Display Integration', () => {
|
||||
})
|
||||
|
||||
it('should render professional plan with plain upgrade button', () => {
|
||||
setupProviderContext({ type: 'professional' })
|
||||
setupBilling({ billing: { subscription: { plan: 'professional' } } })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
@ -313,7 +280,7 @@ describe('Plan Type Display Integration', () => {
|
||||
})
|
||||
|
||||
it('should render team plan with plain-style upgrade button', () => {
|
||||
setupProviderContext({ type: 'team' })
|
||||
setupBilling({ billing: { subscription: { plan: 'team' } } })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
@ -323,7 +290,7 @@ describe('Plan Type Display Integration', () => {
|
||||
})
|
||||
|
||||
it('should show education verify button when enableEducationPlan is true and not yet verified', () => {
|
||||
setupProviderContext({ type: 'sandbox' }, { enableEducationPlan: true })
|
||||
setupBilling({ billing: { subscription: { plan: 'sandbox' } } }, { enableEducationPlan: true })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
@ -331,7 +298,11 @@ describe('Plan Type Display Integration', () => {
|
||||
})
|
||||
|
||||
it('should show education discount to managers without billing permission keys', () => {
|
||||
setupProviderContext({ type: 'sandbox' }, { enableEducationPlan: true }, { is_student: true })
|
||||
setupBilling(
|
||||
{ billing: { subscription: { plan: 'sandbox' } } },
|
||||
{ enableEducationPlan: true },
|
||||
{ is_student: true },
|
||||
)
|
||||
setupConsoleState({ isCurrentWorkspaceManager: true, workspacePermissionKeys: [] })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
@ -340,7 +311,11 @@ describe('Plan Type Display Integration', () => {
|
||||
})
|
||||
|
||||
it('should hide education discount from non-manager members', () => {
|
||||
setupProviderContext({ type: 'sandbox' }, { enableEducationPlan: true }, { is_student: true })
|
||||
setupBilling(
|
||||
{ billing: { subscription: { plan: 'sandbox' } } },
|
||||
{ enableEducationPlan: true },
|
||||
{ is_student: true },
|
||||
)
|
||||
setupConsoleState({
|
||||
isCurrentWorkspaceManager: false,
|
||||
workspacePermissionKeys: ['billing.manage'],
|
||||
@ -361,7 +336,7 @@ describe('Upgrade Flow Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupConsoleState()
|
||||
setupProviderContext({ type: 'sandbox' })
|
||||
setupBilling({ billing: { subscription: { plan: 'sandbox' } } })
|
||||
})
|
||||
|
||||
// UpgradeBtn triggers pricing modal
|
||||
@ -487,7 +462,7 @@ describe('Upgrade Flow Integration', () => {
|
||||
describe('PlanComp upgrade button triggers pricing', () => {
|
||||
it('should open pricing modal when clicking upgrade in sandbox plan', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({ type: 'sandbox' })
|
||||
setupBilling({ billing: { subscription: { plan: 'sandbox' } } })
|
||||
|
||||
render(<PlanComp loc="test-loc" />)
|
||||
|
||||
@ -513,11 +488,7 @@ describe('Capacity Full Components Integration', () => {
|
||||
// AppsFull renders with correct messaging and components
|
||||
describe('AppsFull integration', () => {
|
||||
it('should display upgrade tip and upgrade button for sandbox plan at capacity', () => {
|
||||
setupProviderContext({
|
||||
type: 'sandbox',
|
||||
usage: { buildApps: 5 },
|
||||
total: { buildApps: 5 },
|
||||
})
|
||||
setupBilling({ billing: { subscription: { plan: 'sandbox' } }, apps: { size: 5, limit: 5 } })
|
||||
|
||||
render(<AppsFull loc="test" />)
|
||||
|
||||
@ -532,10 +503,9 @@ describe('Capacity Full Components Integration', () => {
|
||||
})
|
||||
|
||||
it('should display upgrade tip and upgrade button for professional plan', () => {
|
||||
setupProviderContext({
|
||||
type: 'professional',
|
||||
usage: { buildApps: 48 },
|
||||
total: { buildApps: 50 },
|
||||
setupBilling({
|
||||
billing: { subscription: { plan: 'professional' } },
|
||||
apps: { size: 48, limit: 50 },
|
||||
})
|
||||
|
||||
render(<AppsFull loc="test" />)
|
||||
@ -545,11 +515,7 @@ describe('Capacity Full Components Integration', () => {
|
||||
})
|
||||
|
||||
it('should display contact tip and contact button for team plan', () => {
|
||||
setupProviderContext({
|
||||
type: 'team',
|
||||
usage: { buildApps: 200 },
|
||||
total: { buildApps: 200 },
|
||||
})
|
||||
setupBilling({ billing: { subscription: { plan: 'team' } }, apps: { size: 200, limit: 200 } })
|
||||
|
||||
render(<AppsFull loc="test" />)
|
||||
|
||||
@ -562,11 +528,7 @@ describe('Capacity Full Components Integration', () => {
|
||||
|
||||
it('should render progress bar with correct color based on usage percentage', () => {
|
||||
// 100% usage should show error color
|
||||
setupProviderContext({
|
||||
type: 'sandbox',
|
||||
usage: { buildApps: 5 },
|
||||
total: { buildApps: 5 },
|
||||
})
|
||||
setupBilling({ billing: { subscription: { plan: 'sandbox' } }, apps: { size: 5, limit: 5 } })
|
||||
|
||||
const { container } = render(<AppsFull loc="test" />)
|
||||
|
||||
@ -577,10 +539,9 @@ describe('Capacity Full Components Integration', () => {
|
||||
// VectorSpaceFull renders with VectorSpaceInfo and UpgradeBtn
|
||||
describe('VectorSpaceFull integration', () => {
|
||||
it('should display full tip, upgrade button, and vector space usage info', () => {
|
||||
setupProviderContext({
|
||||
type: 'sandbox',
|
||||
usage: { vectorSpace: 50 },
|
||||
total: { vectorSpace: 50 },
|
||||
setupBilling({
|
||||
billing: { subscription: { plan: 'sandbox' } },
|
||||
vectorSpace: { size: 50, limit: 50 },
|
||||
})
|
||||
|
||||
render(<VectorSpaceFull />)
|
||||
@ -598,10 +559,9 @@ describe('Capacity Full Components Integration', () => {
|
||||
// AnnotationFull renders with Usage component and UpgradeBtn
|
||||
describe('AnnotationFull integration', () => {
|
||||
it('should display annotation full tip, upgrade button, and usage info', () => {
|
||||
setupProviderContext({
|
||||
type: 'sandbox',
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
setupBilling({
|
||||
billing: { subscription: { plan: 'sandbox' } },
|
||||
annotation_quota_limit: { size: 10, limit: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFull />)
|
||||
@ -618,10 +578,9 @@ describe('Capacity Full Components Integration', () => {
|
||||
// AnnotationFullModal shows modal with usage and upgrade button
|
||||
describe('AnnotationFullModal integration', () => {
|
||||
it('should render modal with annotation info and upgrade button when show is true', () => {
|
||||
setupProviderContext({
|
||||
type: 'sandbox',
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
setupBilling({
|
||||
billing: { subscription: { plan: 'sandbox' } },
|
||||
annotation_quota_limit: { size: 10, limit: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFullModal show={true} onHide={vi.fn()} />)
|
||||
@ -632,10 +591,9 @@ describe('Capacity Full Components Integration', () => {
|
||||
})
|
||||
|
||||
it('should not render content when show is false', () => {
|
||||
setupProviderContext({
|
||||
type: 'sandbox',
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
setupBilling({
|
||||
billing: { subscription: { plan: 'sandbox' } },
|
||||
annotation_quota_limit: { size: 10, limit: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFullModal show={false} onHide={vi.fn()} />)
|
||||
@ -647,7 +605,7 @@ describe('Capacity Full Components Integration', () => {
|
||||
// TriggerEventsLimitModal renders PlanUpgradeModal with embedded UsageInfo
|
||||
describe('TriggerEventsLimitModal integration', () => {
|
||||
it('should display trigger limit title, usage info, and upgrade button', () => {
|
||||
setupProviderContext({ type: 'professional' })
|
||||
setupBilling({ billing: { subscription: { plan: 'professional' } } })
|
||||
|
||||
render(
|
||||
<TriggerEventsLimitModal
|
||||
@ -678,7 +636,7 @@ describe('Capacity Full Components Integration', () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
const onUpgrade = vi.fn()
|
||||
setupProviderContext({ type: 'professional' })
|
||||
setupBilling({ billing: { subscription: { plan: 'professional' } } })
|
||||
|
||||
render(
|
||||
<TriggerEventsLimitModal
|
||||
@ -710,7 +668,7 @@ describe('PriorityLabel Integration', () => {
|
||||
})
|
||||
|
||||
it('should display "standard" priority for sandbox plan', () => {
|
||||
setupProviderContext({ type: 'sandbox' })
|
||||
setupBilling({ billing: { subscription: { plan: 'sandbox' } } })
|
||||
|
||||
render(<PriorityLabel />)
|
||||
|
||||
@ -718,7 +676,7 @@ describe('PriorityLabel Integration', () => {
|
||||
})
|
||||
|
||||
it('should display "priority" for professional plan with icon', () => {
|
||||
setupProviderContext({ type: 'professional' })
|
||||
setupBilling({ billing: { subscription: { plan: 'professional' } } })
|
||||
|
||||
const { container } = render(<PriorityLabel />)
|
||||
|
||||
@ -728,7 +686,7 @@ describe('PriorityLabel Integration', () => {
|
||||
})
|
||||
|
||||
it('should display "top-priority" for team plan with icon', () => {
|
||||
setupProviderContext({ type: 'team' })
|
||||
setupBilling({ billing: { subscription: { plan: 'team' } } })
|
||||
|
||||
const { container } = render(<PriorityLabel />)
|
||||
|
||||
@ -750,10 +708,9 @@ describe('Usage Display Edge Cases', () => {
|
||||
// Vector space storage mode behavior
|
||||
describe('VectorSpace storage mode in PlanComp', () => {
|
||||
it('should show "< 50" for sandbox plan with low vector space usage', () => {
|
||||
setupProviderContext({
|
||||
type: 'sandbox',
|
||||
usage: { vectorSpace: 10 },
|
||||
total: { vectorSpace: 50 },
|
||||
setupBilling({
|
||||
billing: { subscription: { plan: 'sandbox' } },
|
||||
vectorSpace: { size: 10, limit: 50 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
@ -763,10 +720,9 @@ describe('Usage Display Edge Cases', () => {
|
||||
})
|
||||
|
||||
it('should show indeterminate progress bar for usage below threshold', () => {
|
||||
setupProviderContext({
|
||||
type: 'sandbox',
|
||||
usage: { vectorSpace: 10 },
|
||||
total: { vectorSpace: 50 },
|
||||
setupBilling({
|
||||
billing: { subscription: { plan: 'sandbox' } },
|
||||
vectorSpace: { size: 10, limit: 50 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
@ -776,10 +732,9 @@ describe('Usage Display Edge Cases', () => {
|
||||
})
|
||||
|
||||
it('should show actual usage for pro plan above threshold', () => {
|
||||
setupProviderContext({
|
||||
type: 'professional',
|
||||
usage: { vectorSpace: 1024 },
|
||||
total: { vectorSpace: 5120 },
|
||||
setupBilling({
|
||||
billing: { subscription: { plan: 'professional' } },
|
||||
vectorSpace: { size: 1024, limit: 5120 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
@ -792,11 +747,7 @@ describe('Usage Display Edge Cases', () => {
|
||||
// Progress bar color logic through real components
|
||||
describe('Progress bar color reflects usage severity', () => {
|
||||
it('should show normal color for low usage percentage', () => {
|
||||
setupProviderContext({
|
||||
type: 'sandbox',
|
||||
usage: { buildApps: 1 },
|
||||
total: { buildApps: 5 },
|
||||
})
|
||||
setupBilling({ billing: { subscription: { plan: 'sandbox' } }, apps: { size: 1, limit: 5 } })
|
||||
|
||||
const { container } = render(<PlanComp loc="test" />)
|
||||
|
||||
@ -810,10 +761,9 @@ describe('Usage Display Edge Cases', () => {
|
||||
// Reset days calculation in PlanComp
|
||||
describe('Reset days integration', () => {
|
||||
it('should not show reset for sandbox trigger events (no reset_date)', () => {
|
||||
setupProviderContext({
|
||||
type: 'sandbox',
|
||||
total: { triggerEvents: 3000 },
|
||||
reset: { triggerEvents: null },
|
||||
setupBilling({
|
||||
billing: { subscription: { plan: 'sandbox' } },
|
||||
trigger_event: { limit: 3000, reset_date: 0 },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
@ -826,10 +776,9 @@ describe('Usage Display Edge Cases', () => {
|
||||
})
|
||||
|
||||
it('should show reset for professional trigger events with reset date', () => {
|
||||
setupProviderContext({
|
||||
type: 'professional',
|
||||
total: { triggerEvents: 20000 },
|
||||
reset: { triggerEvents: 14 },
|
||||
setupBilling({
|
||||
billing: { subscription: { plan: 'professional' } },
|
||||
trigger_event: { limit: 20000, reset_date: dayjs().add(14, 'day').startOf('day').unix() },
|
||||
})
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
@ -853,11 +802,7 @@ describe('Cross-Component Upgrade Flow', () => {
|
||||
|
||||
it('should trigger pricing from AppsFull upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({
|
||||
type: 'sandbox',
|
||||
usage: { buildApps: 5 },
|
||||
total: { buildApps: 5 },
|
||||
})
|
||||
setupBilling({ billing: { subscription: { plan: 'sandbox' } }, apps: { size: 5, limit: 5 } })
|
||||
|
||||
render(<AppsFull loc="app-create" />)
|
||||
|
||||
@ -869,10 +814,9 @@ describe('Cross-Component Upgrade Flow', () => {
|
||||
|
||||
it('should trigger pricing from VectorSpaceFull upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({
|
||||
type: 'sandbox',
|
||||
usage: { vectorSpace: 50 },
|
||||
total: { vectorSpace: 50 },
|
||||
setupBilling({
|
||||
billing: { subscription: { plan: 'sandbox' } },
|
||||
vectorSpace: { size: 50, limit: 50 },
|
||||
})
|
||||
|
||||
render(<VectorSpaceFull />)
|
||||
@ -885,10 +829,9 @@ describe('Cross-Component Upgrade Flow', () => {
|
||||
|
||||
it('should trigger pricing from AnnotationFull upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({
|
||||
type: 'sandbox',
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
setupBilling({
|
||||
billing: { subscription: { plan: 'sandbox' } },
|
||||
annotation_quota_limit: { size: 10, limit: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFull />)
|
||||
@ -902,7 +845,7 @@ describe('Cross-Component Upgrade Flow', () => {
|
||||
it('should trigger pricing from TriggerEventsLimitModal through PlanUpgradeModal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
setupProviderContext({ type: 'professional' })
|
||||
setupBilling({ billing: { subscription: { plan: 'professional' } } })
|
||||
|
||||
render(
|
||||
<TriggerEventsLimitModal
|
||||
@ -924,10 +867,9 @@ describe('Cross-Component Upgrade Flow', () => {
|
||||
|
||||
it('should trigger pricing from AnnotationFullModal upgrade button', async () => {
|
||||
const user = userEvent.setup()
|
||||
setupProviderContext({
|
||||
type: 'sandbox',
|
||||
usage: { annotatedResponse: 10 },
|
||||
total: { annotatedResponse: 10 },
|
||||
setupBilling({
|
||||
billing: { subscription: { plan: 'sandbox' } },
|
||||
annotation_quota_limit: { size: 10, limit: 10 },
|
||||
})
|
||||
|
||||
render(<AnnotationFullModal show={true} onHide={vi.fn()} />)
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
import type {
|
||||
GetFeaturesResponse,
|
||||
GetFeaturesVectorSpaceResponse,
|
||||
} from '@dify/contracts/api/console/features/types.gen'
|
||||
import type { RenderOptions } from '@testing-library/react'
|
||||
import type { ReactElement } from 'react'
|
||||
import type { UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/type'
|
||||
import type { DeepPartial } from '@/test/console/system-features'
|
||||
import { cleanup, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import PlanComp from '@/app/components/billing/plan'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import {
|
||||
@ -13,19 +16,15 @@ import {
|
||||
} from '@/test/console/query-data'
|
||||
import { render as renderWithConsoleState } from '@/test/console/render'
|
||||
|
||||
let mockProviderCtx: Record<string, unknown> = {}
|
||||
let mockFeatures: DeepPartial<GetFeaturesResponse> = {}
|
||||
let mockVectorSpace: GetFeaturesVectorSpaceResponse = { size: 0, limit: 50, usage_unknown: false }
|
||||
let mockConsoleState: Record<string, unknown> = {}
|
||||
let mockEducationStatus = { is_student: false, allow_refresh: false, expire_at: null }
|
||||
|
||||
const render = (ui: ReactElement, options: RenderOptions = {}) => {
|
||||
const queryClient = createConsoleQueryClient()
|
||||
const plan = mockProviderCtx.plan as {
|
||||
usage: { vectorSpace: number }
|
||||
total: { vectorSpace: number }
|
||||
}
|
||||
queryClient.setQueryData(consoleQuery.features.vectorSpace.get.queryOptions().queryKey, {
|
||||
size: plan.usage.vectorSpace,
|
||||
limit: plan.total.vectorSpace,
|
||||
...mockVectorSpace,
|
||||
usage_unknown: false,
|
||||
})
|
||||
seedEducationStatus(queryClient, mockEducationStatus)
|
||||
@ -33,6 +32,7 @@ const render = (ui: ReactElement, options: RenderOptions = {}) => {
|
||||
accountProfile: mockConsoleState.userProfile as { email?: string },
|
||||
accountProfileMeta: { currentVersion: '1.0.0' },
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
features: mockFeatures,
|
||||
queryClient,
|
||||
})
|
||||
return renderWithConsoleState(ui, { ...options, wrapper })
|
||||
@ -42,9 +42,6 @@ const render = (ui: ReactElement, options: RenderOptions = {}) => {
|
||||
const mockSetShowPricingModal = vi.fn()
|
||||
|
||||
// ─── Context mocks ───────────────────────────────────────────────────────────
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockProviderCtx,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/workspace-state', async () => {
|
||||
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
@ -63,25 +60,13 @@ vi.mock('@/next/navigation', () => ({
|
||||
}))
|
||||
|
||||
// ─── Test data factories ────────────────────────────────────────────────────
|
||||
type PlanOverrides = {
|
||||
type?: string
|
||||
usage?: Partial<UsagePlanInfo>
|
||||
total?: Partial<UsagePlanInfo>
|
||||
reset?: Partial<UsageResetInfo>
|
||||
type BillingOverrides = DeepPartial<GetFeaturesResponse> & {
|
||||
vectorSpace?: Partial<GetFeaturesVectorSpaceResponse>
|
||||
}
|
||||
|
||||
const createPlanData = (overrides: PlanOverrides = {}) => ({
|
||||
...defaultPlan,
|
||||
...overrides,
|
||||
type: overrides.type ?? defaultPlan.type,
|
||||
usage: { ...defaultPlan.usage, ...overrides.usage },
|
||||
total: { ...defaultPlan.total, ...overrides.total },
|
||||
reset: { ...defaultPlan.reset, ...overrides.reset },
|
||||
})
|
||||
|
||||
const setupContexts = (
|
||||
planOverrides: PlanOverrides = {},
|
||||
providerOverrides: Record<string, unknown> = {},
|
||||
const setupBilling = (
|
||||
{ vectorSpace, ...features }: BillingOverrides = {},
|
||||
education: { enableEducationPlan?: boolean } = {},
|
||||
appOverrides: Record<string, unknown> = {},
|
||||
educationStatus: Partial<typeof mockEducationStatus> = {},
|
||||
) => {
|
||||
@ -91,11 +76,8 @@ const setupContexts = (
|
||||
expire_at: null,
|
||||
...educationStatus,
|
||||
}
|
||||
mockProviderCtx = {
|
||||
plan: createPlanData(planOverrides),
|
||||
enableEducationPlan: false,
|
||||
...providerOverrides,
|
||||
}
|
||||
mockFeatures = { ...features, education: { enabled: education.enableEducationPlan ?? false } }
|
||||
mockVectorSpace = { size: 0, limit: 50, usage_unknown: false, ...vectorSpace }
|
||||
mockConsoleState = {
|
||||
isCurrentWorkspaceManager: true,
|
||||
userProfile: { email: 'student@university.edu' },
|
||||
@ -109,13 +91,13 @@ describe('Education Verification Flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
cleanup()
|
||||
setupContexts()
|
||||
setupBilling()
|
||||
})
|
||||
|
||||
// ─── 1. Education Button Visibility ─────────────────────────────────────
|
||||
describe('Education button visibility', () => {
|
||||
it('should not show verify button when enableEducationPlan is false', () => {
|
||||
setupContexts({}, { enableEducationPlan: false })
|
||||
setupBilling({}, { enableEducationPlan: false })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
@ -123,7 +105,7 @@ describe('Education Verification Flow', () => {
|
||||
})
|
||||
|
||||
it('should show verify button when enableEducationPlan is true and not yet verified', () => {
|
||||
setupContexts({}, { enableEducationPlan: true })
|
||||
setupBilling({}, { enableEducationPlan: true })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
@ -134,7 +116,7 @@ describe('Education Verification Flow', () => {
|
||||
})
|
||||
|
||||
it('should not show verify button when already verified and not about to expire', () => {
|
||||
setupContexts({}, { enableEducationPlan: true }, {}, { is_student: true })
|
||||
setupBilling({}, { enableEducationPlan: true }, {}, { is_student: true })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
@ -142,12 +124,7 @@ describe('Education Verification Flow', () => {
|
||||
})
|
||||
|
||||
it('should show verify button when the education status allows refresh', () => {
|
||||
setupContexts(
|
||||
{},
|
||||
{ enableEducationPlan: true },
|
||||
{},
|
||||
{ is_student: true, allow_refresh: true },
|
||||
)
|
||||
setupBilling({}, { enableEducationPlan: true }, {}, { is_student: true, allow_refresh: true })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
@ -158,7 +135,10 @@ describe('Education Verification Flow', () => {
|
||||
// ─── 2. Education + Upgrade Coexistence ─────────────────────────────────
|
||||
describe('Education and upgrade button coexistence', () => {
|
||||
it('should show both education verify and upgrade buttons for sandbox user', () => {
|
||||
setupContexts({ type: 'sandbox' }, { enableEducationPlan: true })
|
||||
setupBilling(
|
||||
{ billing: { subscription: { plan: 'sandbox' } } },
|
||||
{ enableEducationPlan: true },
|
||||
)
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
@ -167,7 +147,7 @@ describe('Education Verification Flow', () => {
|
||||
})
|
||||
|
||||
it('should show team plan with plain upgrade button and education button', () => {
|
||||
setupContexts({ type: 'team' }, { enableEducationPlan: true })
|
||||
setupBilling({ billing: { subscription: { plan: 'team' } } }, { enableEducationPlan: true })
|
||||
|
||||
render(<PlanComp loc="test" />)
|
||||
|
||||
|
||||
@ -133,6 +133,20 @@ function ZendeskConversationSync() {
|
||||
deploymentEdition: data.deployment_edition,
|
||||
}),
|
||||
})
|
||||
const { data: plan } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: systemFeatures.deploymentEdition === 'CLOUD' && Boolean(ZENDESK_FIELD_IDS.PLAN),
|
||||
select: (data) => data.billing.subscription.plan,
|
||||
}),
|
||||
)
|
||||
useEffect(() => {
|
||||
if (systemFeatures.deploymentEdition !== 'CLOUD' || !plan || !ZENDESK_FIELD_IDS.PLAN) return
|
||||
zendeskRuntime.setConversationFields(
|
||||
[{ id: ZENDESK_FIELD_IDS.PLAN, value: `${plan}-plan` }],
|
||||
systemFeatures.deploymentEdition,
|
||||
)
|
||||
}, [plan, systemFeatures.deploymentEdition])
|
||||
|
||||
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
|
||||
const { data: versionData } = useQuery(
|
||||
consoleQuery.version.get.queryOptions({
|
||||
|
||||
@ -2,7 +2,6 @@ import type { DeploymentEdition } from '@dify/contracts/api/console/system-featu
|
||||
import type { RenderOptions } from '@testing-library/react'
|
||||
import type { MockedFunction } from 'vite-plus/test'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import { useProviderContext as actualUseProviderContext } from '@/context/provider-context'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import APIKeyInfoPanel from '../index'
|
||||
@ -43,15 +42,12 @@ const defaultProviderContext = {
|
||||
isLoadingModelProviders: false,
|
||||
isSuccessModelProviders: false,
|
||||
textGenerationModelList: [],
|
||||
supportRetrievalMethods: [],
|
||||
isAPIKeySet: false,
|
||||
plan: defaultPlan,
|
||||
|
||||
enableSkill: false,
|
||||
enableReplaceWebAppLogo: false,
|
||||
modelLoadBalancingEnabled: false,
|
||||
enableEducationPlan: false,
|
||||
webappCopyrightEnabled: false,
|
||||
isAllowTransferWorkspace: false,
|
||||
isAllowPublishAsCustomKnowledgePipelineTemplate: false,
|
||||
humanInputEmailDeliveryEnabled: false,
|
||||
|
||||
77
web/app/components/billing/__tests__/query-state.spec.tsx
Normal file
77
web/app/components/billing/__tests__/query-state.spec.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { act, render, screen, within } from '@testing-library/react'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import {
|
||||
createConsoleQueryClient,
|
||||
createConsoleQueryWrapper,
|
||||
seedFeatures,
|
||||
} from '@/test/console/query-data'
|
||||
import AnnotationUsage from '../annotation-full/usage'
|
||||
import Billing from '../billing-page'
|
||||
|
||||
vi.mock('@/service/base', () => ({
|
||||
request: vi.fn(() => new Promise(() => {})),
|
||||
sseGeneratorPost: vi.fn(),
|
||||
}))
|
||||
vi.mock('../upgrade-btn', () => ({ default: () => null }))
|
||||
vi.mock('../hooks/use-education-discount', () => ({
|
||||
useEducationDiscount: () => ({
|
||||
handleEducationDiscount: vi.fn(),
|
||||
isEducationDiscountLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
it('reveals the plan and all usage together when both billing queries have data', async () => {
|
||||
const queryClient = createConsoleQueryClient()
|
||||
const { wrapper } = createConsoleQueryWrapper({
|
||||
queryClient,
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
})
|
||||
render(<Billing />, { wrapper })
|
||||
expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('billing.plans.sandbox.name')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('group', { name: 'billing.usagePage.buildApps' }),
|
||||
).not.toBeInTheDocument()
|
||||
await act(async () => {
|
||||
seedFeatures(queryClient, {
|
||||
billing: { subscription: { plan: 'professional' } },
|
||||
apps: { size: 17, limit: 50 },
|
||||
})
|
||||
})
|
||||
expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('group', { name: 'billing.usagePage.buildApps' }),
|
||||
).not.toBeInTheDocument()
|
||||
await act(async () => {
|
||||
queryClient.setQueryData(consoleQuery.features.vectorSpace.get.queryKey(), {
|
||||
size: 256,
|
||||
limit: 900,
|
||||
usage_unknown: false,
|
||||
})
|
||||
})
|
||||
const apps = await screen.findByRole('group', { name: 'billing.usagePage.buildApps' })
|
||||
expect(within(apps).getByText('17')).toBeInTheDocument()
|
||||
expect(within(apps).getByText('50')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.plans.professional.name')).toBeInTheDocument()
|
||||
const storage = screen.getByRole('group', { name: 'billing.usagePage.vectorSpace' })
|
||||
expect(within(storage).getByText('256')).toBeInTheDocument()
|
||||
expect(within(storage).getByText('900MB')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('status', { name: 'appApi.loading' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders annotation usage only from returned data and preserves zero as an unlimited quota', async () => {
|
||||
const queryClient = createConsoleQueryClient()
|
||||
const { wrapper } = createConsoleQueryWrapper({ queryClient })
|
||||
render(<AnnotationUsage />, { wrapper })
|
||||
expect(
|
||||
screen.queryByRole('group', { name: 'billing.annotatedResponse.quotaTitle' }),
|
||||
).not.toBeInTheDocument()
|
||||
await act(async () => {
|
||||
seedFeatures(queryClient, { annotation_quota_limit: { size: 4, limit: 0 } })
|
||||
})
|
||||
const annotation = await screen.findByRole('group', {
|
||||
name: 'billing.annotatedResponse.quotaTitle',
|
||||
})
|
||||
expect(within(annotation).getByText('4')).toBeInTheDocument()
|
||||
expect(within(annotation).getByText('billing.plansCommon.unlimited')).toBeInTheDocument()
|
||||
})
|
||||
@ -1,10 +1,12 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { MessageFastPlus } from '../../base/icons/src/vender/line/communication'
|
||||
import UsageInfo from '../usage-info'
|
||||
import { parseLimit } from '../utils'
|
||||
|
||||
type Props = Readonly<{
|
||||
className?: string
|
||||
@ -12,15 +14,19 @@ type Props = Readonly<{
|
||||
|
||||
const Usage: FC<Props> = ({ className }) => {
|
||||
const { t } = useTranslation()
|
||||
const { plan } = useProviderContext()
|
||||
const { usage, total } = plan
|
||||
const { data: annotationQuota } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
select: (features) => features.annotation_quota_limit,
|
||||
}),
|
||||
)
|
||||
if (!annotationQuota) return null
|
||||
return (
|
||||
<UsageInfo
|
||||
className={className}
|
||||
Icon={MessageFastPlus}
|
||||
name={t(($) => $['annotatedResponse.quotaTitle'], { ns: 'billing' })}
|
||||
usage={usage.annotatedResponse}
|
||||
total={total.annotatedResponse}
|
||||
usage={annotationQuota.size}
|
||||
total={parseLimit(annotationQuota.limit)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { usePrefetchQuery, useQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
|
||||
import { deploymentEditionAtom } from '@/features/system-features/state'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
@ -14,6 +15,7 @@ const Billing: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
|
||||
const deploymentEdition = useAtomValue(deploymentEditionAtom)
|
||||
usePrefetchQuery(consoleQuery.features.vectorSpace.get.queryOptions())
|
||||
const { data: billing } = useQuery(
|
||||
consoleQuery.billing.invoices.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD' && isCurrentWorkspaceManager,
|
||||
@ -23,7 +25,15 @@ const Billing: FC = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PlanComp loc="billing-page" />
|
||||
<div className="grid min-h-132 xl:min-h-120">
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<Loading className="rounded-2xl border-[0.5px] border-effects-highlight-lightmode-off bg-background-section-burn" />
|
||||
}
|
||||
>
|
||||
<PlanComp loc="billing-page" />
|
||||
</React.Suspense>
|
||||
</div>
|
||||
{deploymentEdition === 'CLOUD' && isCurrentWorkspaceManager && (
|
||||
<a
|
||||
className={cn(
|
||||
|
||||
@ -69,31 +69,3 @@ export const ALL_PLANS: Record<CloudPlan, PlanInfo> = {
|
||||
logHistory: NUM_INFINITE,
|
||||
},
|
||||
}
|
||||
|
||||
export const defaultPlan = {
|
||||
type: 'sandbox' as const,
|
||||
usage: {
|
||||
documents: 50,
|
||||
vectorSpace: 1,
|
||||
buildApps: 1,
|
||||
teamMembers: 1,
|
||||
annotatedResponse: 1,
|
||||
documentsUploadQuota: 0,
|
||||
apiRateLimit: 0,
|
||||
triggerEvents: 0,
|
||||
},
|
||||
total: {
|
||||
documents: 50,
|
||||
vectorSpace: 10,
|
||||
buildApps: 10,
|
||||
teamMembers: 1,
|
||||
annotatedResponse: 10,
|
||||
documentsUploadQuota: 0,
|
||||
apiRateLimit: ALL_PLANS.sandbox.apiRateLimit,
|
||||
triggerEvents: ALL_PLANS.sandbox.triggerEvents,
|
||||
},
|
||||
reset: {
|
||||
apiRateLimit: null,
|
||||
triggerEvents: null,
|
||||
},
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import { baseProviderContextValue } from '@/context/provider-context'
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { render } from '@/test/console/render'
|
||||
import PlanComp from '../index'
|
||||
@ -11,24 +10,6 @@ vi.mock('@/context/workspace-state', async () => {
|
||||
}))
|
||||
})
|
||||
|
||||
vi.mock('@/context/provider-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/provider-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useProviderContext: () => ({
|
||||
...baseProviderContextValue,
|
||||
enableEducationPlan: true,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/billing/hooks/use-education-discount', () => ({
|
||||
useEducationDiscount: () => ({
|
||||
handleEducationDiscount: vi.fn(),
|
||||
isEducationDiscountLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/billing/upgrade-btn', () => ({
|
||||
default: () => <button type="button">View Plan</button>,
|
||||
}))
|
||||
@ -37,10 +18,6 @@ vi.mock('@/app/components/billing/usage-info', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/billing/usage-info/apps-info', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/billing/usage-info/vector-space-info', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
@ -54,6 +31,7 @@ vi.mock('../assets', () => ({
|
||||
const renderPlan = (educationStatus = { allow_refresh: false, is_student: false }) => {
|
||||
const { wrapper } = createConsoleQueryWrapper({
|
||||
educationStatus,
|
||||
features: { education: { enabled: true } },
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
})
|
||||
|
||||
|
||||
@ -2,14 +2,13 @@
|
||||
import type { EducationStatusResponse } from '@dify/contracts/api/console/account/types.gen'
|
||||
import type { FC } from 'react'
|
||||
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
|
||||
import { RiBook2Line, RiFileEditLine, RiGroupLine } from '@remixicon/react'
|
||||
import { RiApps2Line, RiBook2Line, RiFileEditLine, RiGroupLine } from '@remixicon/react'
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ApiAggregate, TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
|
||||
import UsageInfo from '@/app/components/billing/usage-info'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { isCurrentWorkspaceManagerAtom } from '@/context/workspace-state'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import Link from '@/next/link'
|
||||
@ -19,8 +18,8 @@ import Loading from '../../base/icons/src/public/thought/Loading'
|
||||
import { NUM_INFINITE } from '../config'
|
||||
import { useEducationDiscount } from '../hooks/use-education-discount'
|
||||
import UpgradeBtn from '../upgrade-btn'
|
||||
import AppsInfo from '../usage-info/apps-info'
|
||||
import VectorSpaceInfo from '../usage-info/vector-space-info'
|
||||
import { getResetInDaysFromDate, parseLimit, parseRateLimit } from '../utils'
|
||||
import { Professional, Sandbox, Team } from './assets'
|
||||
|
||||
type Props = Readonly<{
|
||||
@ -40,7 +39,8 @@ const PlanComp: FC<Props> = ({ loc }) => {
|
||||
})
|
||||
const isCloudEdition = deploymentEdition === 'CLOUD'
|
||||
const isCurrentWorkspaceManager = useAtomValue(isCurrentWorkspaceManagerAtom)
|
||||
const { plan, enableEducationPlan } = useProviderContext()
|
||||
const { data: features } = useSuspenseQuery(consoleQuery.features.get.queryOptions())
|
||||
const enableEducationPlan = features.education.enabled
|
||||
const { data: educationStatus } = useQuery(
|
||||
consoleQuery.account.education.get.queryOptions({
|
||||
enabled: enableEducationPlan,
|
||||
@ -48,16 +48,17 @@ const PlanComp: FC<Props> = ({ loc }) => {
|
||||
}),
|
||||
)
|
||||
const { isAboutToExpire = false, isEducationAccount = false } = educationStatus ?? {}
|
||||
const { type } = plan
|
||||
|
||||
const { usage, total, reset } = plan
|
||||
const type = features.billing.subscription.plan
|
||||
const triggerEventsLimit = parseRateLimit(features.trigger_event.limit)
|
||||
const apiRateLimit = parseRateLimit(features.api_rate_limit.limit)
|
||||
const apiRateLimitReset = getResetInDaysFromDate(features.api_rate_limit.reset_date)
|
||||
const triggerEventsResetInDays =
|
||||
type === 'professional' && total.triggerEvents !== NUM_INFINITE
|
||||
? (reset.triggerEvents ?? undefined)
|
||||
type === 'professional' && triggerEventsLimit !== NUM_INFINITE
|
||||
? (getResetInDaysFromDate(features.trigger_event.reset_date) ?? undefined)
|
||||
: undefined
|
||||
const apiRateLimitResetInDays = (() => {
|
||||
if (total.apiRateLimit === NUM_INFINITE) return undefined
|
||||
if (typeof reset.apiRateLimit === 'number') return reset.apiRateLimit
|
||||
if (apiRateLimit === NUM_INFINITE) return undefined
|
||||
if (typeof apiRateLimitReset === 'number') return apiRateLimitReset
|
||||
if (type === 'sandbox') return getDaysUntilEndOfMonth()
|
||||
return undefined
|
||||
})()
|
||||
@ -66,9 +67,9 @@ const PlanComp: FC<Props> = ({ loc }) => {
|
||||
return (
|
||||
<div className="relative rounded-2xl border-[0.5px] border-effects-highlight-lightmode-off bg-background-section-burn">
|
||||
<div className="p-6 pb-2">
|
||||
{plan.type === 'sandbox' && <Sandbox />}
|
||||
{plan.type === 'professional' && <Professional />}
|
||||
{plan.type === 'team' && <Team />}
|
||||
{type === 'sandbox' && <Sandbox />}
|
||||
{type === 'professional' && <Professional />}
|
||||
{type === 'team' && <Team />}
|
||||
<div className="mt-1 flex items-center">
|
||||
<div className="grow">
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
@ -110,41 +111,46 @@ const PlanComp: FC<Props> = ({ loc }) => {
|
||||
</div>
|
||||
{/* Plan detail */}
|
||||
<div className="grid grid-cols-3 content-start gap-1 p-2">
|
||||
<AppsInfo />
|
||||
<UsageInfo
|
||||
Icon={RiApps2Line}
|
||||
name={t(($) => $['usagePage.buildApps'], { ns: 'billing' })}
|
||||
usage={features.apps.size}
|
||||
total={parseLimit(features.apps.limit)}
|
||||
/>
|
||||
<UsageInfo
|
||||
Icon={RiGroupLine}
|
||||
name={t(($) => $['usagePage.teamMembers'], { ns: 'billing' })}
|
||||
usage={usage.teamMembers}
|
||||
total={total.teamMembers}
|
||||
usage={features.members.size}
|
||||
total={parseLimit(features.members.limit)}
|
||||
/>
|
||||
<UsageInfo
|
||||
Icon={RiBook2Line}
|
||||
name={t(($) => $['usagePage.documentsUploadQuota'], { ns: 'billing' })}
|
||||
usage={usage.documentsUploadQuota}
|
||||
total={total.documentsUploadQuota}
|
||||
usage={features.documents_upload_quota.size}
|
||||
total={parseLimit(features.documents_upload_quota.limit)}
|
||||
/>
|
||||
<VectorSpaceInfo />
|
||||
<UsageInfo
|
||||
Icon={RiFileEditLine}
|
||||
name={t(($) => $['usagePage.annotationQuota'], { ns: 'billing' })}
|
||||
usage={usage.annotatedResponse}
|
||||
total={total.annotatedResponse}
|
||||
usage={features.annotation_quota_limit.size}
|
||||
total={parseLimit(features.annotation_quota_limit.limit)}
|
||||
/>
|
||||
<UsageInfo
|
||||
Icon={TriggerAll}
|
||||
name={t(($) => $['usagePage.triggerEvents'], { ns: 'billing' })}
|
||||
usage={usage.triggerEvents}
|
||||
total={total.triggerEvents}
|
||||
usage={features.trigger_event.usage}
|
||||
total={triggerEventsLimit}
|
||||
tooltip={t(($) => $['plansCommon.triggerEvents.tooltip'], { ns: 'billing' }) as string}
|
||||
resetInDays={triggerEventsResetInDays}
|
||||
/>
|
||||
<UsageInfo
|
||||
Icon={ApiAggregate}
|
||||
name={t(($) => $['plansCommon.apiRateLimit'], { ns: 'billing' })}
|
||||
usage={usage.apiRateLimit}
|
||||
total={total.apiRateLimit}
|
||||
usage={features.api_rate_limit.usage}
|
||||
total={apiRateLimit}
|
||||
tooltip={
|
||||
total.apiRateLimit === NUM_INFINITE
|
||||
apiRateLimit === NUM_INFINITE
|
||||
? undefined
|
||||
: (t(($) => $['plansCommon.apiRateLimitTooltip'], { ns: 'billing' }) as string)
|
||||
}
|
||||
|
||||
@ -18,18 +18,3 @@ export type PlanInfo = {
|
||||
triggerEvents: number
|
||||
annotatedResponse: number
|
||||
}
|
||||
|
||||
export type UsagePlanInfo = {
|
||||
buildApps: number
|
||||
teamMembers: number
|
||||
annotatedResponse: number
|
||||
documentsUploadQuota: number
|
||||
apiRateLimit: number
|
||||
triggerEvents: number
|
||||
vectorSpace: number
|
||||
}
|
||||
|
||||
export type UsageResetInfo = {
|
||||
apiRateLimit?: number | null
|
||||
triggerEvents?: number | null
|
||||
}
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { RiApps2Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import UsageInfo from '../usage-info'
|
||||
|
||||
type Props = Readonly<{
|
||||
className?: string
|
||||
}>
|
||||
|
||||
const AppsInfo: FC<Props> = ({ className }) => {
|
||||
const { t } = useTranslation()
|
||||
const { plan } = useProviderContext()
|
||||
const { usage, total } = plan
|
||||
return (
|
||||
<UsageInfo
|
||||
className={className}
|
||||
Icon={RiApps2Line}
|
||||
name={t(($) => $['usagePage.buildApps'], { ns: 'billing' })}
|
||||
usage={usage.buildApps}
|
||||
total={total.buildApps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export default React.memo(AppsInfo)
|
||||
@ -1,10 +1,9 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { RiHardDrive3Line } from '@remixicon/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useSuspenseQueries } from '@tanstack/react-query'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import UsageInfo from '../usage-info'
|
||||
import { getPlanVectorSpaceLimitMB } from '../utils'
|
||||
@ -18,11 +17,14 @@ const STORAGE_THRESHOLD_MB = getPlanVectorSpaceLimitMB('sandbox')
|
||||
|
||||
const VectorSpaceInfo: FC<Props> = ({ className }) => {
|
||||
const { t } = useTranslation()
|
||||
const { plan } = useProviderContext()
|
||||
const { data: vectorSpace } = useQuery(consoleQuery.features.vectorSpace.get.queryOptions())
|
||||
const vectorSpaceUsage = vectorSpace?.size ?? plan.usage.vectorSpace
|
||||
const vectorSpaceLimit = vectorSpace?.limit ?? getPlanVectorSpaceLimitMB(plan.type)
|
||||
const isSandbox = plan.type === 'sandbox'
|
||||
const [{ data: plan }, { data: vectorSpace }] = useSuspenseQueries({
|
||||
queries: [
|
||||
consoleQuery.features.get.queryOptions({
|
||||
select: (features) => features.billing.subscription.plan,
|
||||
}),
|
||||
consoleQuery.features.vectorSpace.get.queryOptions(),
|
||||
],
|
||||
})
|
||||
|
||||
return (
|
||||
<UsageInfo
|
||||
@ -30,15 +32,15 @@ const VectorSpaceInfo: FC<Props> = ({ className }) => {
|
||||
Icon={RiHardDrive3Line}
|
||||
name={t(($) => $['usagePage.vectorSpace'], { ns: 'billing' })}
|
||||
tooltip={t(($) => $['usagePage.vectorSpaceTooltip'], { ns: 'billing' }) as string}
|
||||
usage={vectorSpaceUsage}
|
||||
total={vectorSpaceLimit}
|
||||
usage={vectorSpace.size}
|
||||
total={vectorSpace.limit}
|
||||
unit="MB"
|
||||
unitPosition="inline"
|
||||
storageMode
|
||||
storageThreshold={STORAGE_THRESHOLD_MB}
|
||||
storageTooltip={t(($) => $['usagePage.storageThresholdTooltip'], { ns: 'billing' }) as string}
|
||||
isSandboxPlan={isSandbox}
|
||||
usageUnknown={vectorSpace?.usage_unknown}
|
||||
isSandboxPlan={plan === 'sandbox'}
|
||||
usageUnknown={vectorSpace.usage_unknown}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import type { GetFeaturesResponse } from '@dify/contracts/api/console/features/types.gen'
|
||||
import { getPlanVectorSpaceLimitMB, parseCurrentPlan, parseVectorSpaceToMB } from '../index'
|
||||
import {
|
||||
getPlanVectorSpaceLimitMB,
|
||||
getResetInDaysFromDate,
|
||||
parseLimit,
|
||||
parseRateLimit,
|
||||
parseVectorSpaceToMB,
|
||||
} from '../index'
|
||||
|
||||
describe('billing utils', () => {
|
||||
// parseVectorSpaceToMB tests
|
||||
@ -42,275 +47,34 @@ describe('billing utils', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// parseCurrentPlan tests
|
||||
describe('parseCurrentPlan', () => {
|
||||
const createMockPlanData = (
|
||||
overrides: Partial<GetFeaturesResponse> = {},
|
||||
): GetFeaturesResponse => ({
|
||||
annotation_quota_limit: {
|
||||
size: 5,
|
||||
limit: 10,
|
||||
},
|
||||
api_rate_limit: {
|
||||
usage: 0,
|
||||
limit: 5000,
|
||||
reset_date: -1,
|
||||
},
|
||||
apps: {
|
||||
size: 2,
|
||||
limit: 5,
|
||||
},
|
||||
billing: {
|
||||
subscription: {
|
||||
interval: '',
|
||||
plan: 'sandbox',
|
||||
},
|
||||
},
|
||||
can_replace_logo: false,
|
||||
dataset_operator_enabled: false,
|
||||
docs_processing: '',
|
||||
documents_upload_quota: {
|
||||
size: 20,
|
||||
limit: 0,
|
||||
},
|
||||
education: {
|
||||
activated: false,
|
||||
enabled: false,
|
||||
},
|
||||
enable_skill: false,
|
||||
human_input_email_delivery_enabled: false,
|
||||
is_allow_transfer_workspace: false,
|
||||
knowledge_pipeline: {
|
||||
publish_enabled: false,
|
||||
},
|
||||
knowledge_rate_limit: 0,
|
||||
members: {
|
||||
size: 1,
|
||||
limit: 1,
|
||||
},
|
||||
model_load_balancing_enabled: false,
|
||||
next_credit_reset_date: 0,
|
||||
trigger_event: {
|
||||
usage: 0,
|
||||
limit: 3000,
|
||||
reset_date: -1,
|
||||
},
|
||||
vector_space: null,
|
||||
webapp_copyright_enabled: false,
|
||||
workspace_members: {
|
||||
enabled: false,
|
||||
size: 0,
|
||||
limit: 0,
|
||||
},
|
||||
...overrides,
|
||||
describe('quota reset dates', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(2026, 8, 7, 12))
|
||||
})
|
||||
|
||||
it('should parse plan type correctly', () => {
|
||||
const data = createMockPlanData()
|
||||
const result = parseCurrentPlan(data)
|
||||
expect(result.type).toBe('sandbox')
|
||||
afterEach(() => vi.useRealTimers())
|
||||
it('handles Unix timestamps in seconds and milliseconds', () => {
|
||||
const resetDate = new Date(2026, 8, 14).getTime()
|
||||
expect(getResetInDaysFromDate(resetDate)).toBe(7)
|
||||
expect(getResetInDaysFromDate(resetDate / 1000)).toBe(7)
|
||||
})
|
||||
|
||||
it('should parse usage values correctly', () => {
|
||||
const data = createMockPlanData()
|
||||
const result = parseCurrentPlan(data)
|
||||
|
||||
expect(result.usage.vectorSpace).toBe(0)
|
||||
expect(result.usage.buildApps).toBe(2)
|
||||
expect(result.usage.teamMembers).toBe(1)
|
||||
expect(result.usage.annotatedResponse).toBe(5)
|
||||
expect(result.usage.documentsUploadQuota).toBe(20)
|
||||
it('handles calendar dates and omits absent or past resets', () => {
|
||||
expect(getResetInDaysFromDate(20260914)).toBe(7)
|
||||
expect(getResetInDaysFromDate(0)).toBeNull()
|
||||
expect(getResetInDaysFromDate(-1)).toBeNull()
|
||||
expect(getResetInDaysFromDate(20260901)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('should parse total limits correctly', () => {
|
||||
const data = createMockPlanData()
|
||||
const result = parseCurrentPlan(data)
|
||||
|
||||
expect(result.total.vectorSpace).toBe(50)
|
||||
expect(result.total.buildApps).toBe(5)
|
||||
expect(result.total.teamMembers).toBe(1)
|
||||
expect(result.total.annotatedResponse).toBe(10)
|
||||
describe('quota display', () => {
|
||||
it('displays zero count limits as unlimited', () => {
|
||||
expect(parseLimit(0)).toBe(-1)
|
||||
expect(parseLimit(10)).toBe(10)
|
||||
})
|
||||
|
||||
it('should not read vector space usage from current plan info', () => {
|
||||
const data = createMockPlanData()
|
||||
const result = parseCurrentPlan(data)
|
||||
|
||||
expect(result.usage.vectorSpace).toBe(0)
|
||||
expect(result.total.vectorSpace).toBe(50)
|
||||
})
|
||||
|
||||
it('should derive vector space total from plan config', () => {
|
||||
const data = createMockPlanData({
|
||||
billing: {
|
||||
subscription: {
|
||||
interval: '',
|
||||
plan: 'professional',
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = parseCurrentPlan(data)
|
||||
|
||||
expect(result.usage.vectorSpace).toBe(0)
|
||||
expect(result.total.vectorSpace).toBe(5 * 1024)
|
||||
})
|
||||
|
||||
it('should convert 0 limits to NUM_INFINITE (-1)', () => {
|
||||
const data = createMockPlanData({
|
||||
documents_upload_quota: {
|
||||
size: 20,
|
||||
limit: 0,
|
||||
},
|
||||
})
|
||||
const result = parseCurrentPlan(data)
|
||||
expect(result.total.documentsUploadQuota).toBe(-1)
|
||||
})
|
||||
|
||||
it('should handle api_rate_limit quota', () => {
|
||||
const data = createMockPlanData({
|
||||
api_rate_limit: {
|
||||
usage: 100,
|
||||
limit: 5000,
|
||||
reset_date: 0,
|
||||
},
|
||||
})
|
||||
const result = parseCurrentPlan(data)
|
||||
|
||||
expect(result.usage.apiRateLimit).toBe(100)
|
||||
expect(result.total.apiRateLimit).toBe(5000)
|
||||
})
|
||||
|
||||
it('should handle trigger_event quota', () => {
|
||||
const data = createMockPlanData({
|
||||
trigger_event: {
|
||||
usage: 50,
|
||||
limit: 3000,
|
||||
reset_date: 0,
|
||||
},
|
||||
})
|
||||
const result = parseCurrentPlan(data)
|
||||
|
||||
expect(result.usage.triggerEvents).toBe(50)
|
||||
expect(result.total.triggerEvents).toBe(3000)
|
||||
})
|
||||
|
||||
it('should convert 0 or -1 rate limits to NUM_INFINITE', () => {
|
||||
const data = createMockPlanData({
|
||||
api_rate_limit: {
|
||||
usage: 0,
|
||||
limit: 0,
|
||||
reset_date: 0,
|
||||
},
|
||||
})
|
||||
const result = parseCurrentPlan(data)
|
||||
expect(result.total.apiRateLimit).toBe(-1)
|
||||
|
||||
const data2 = createMockPlanData({
|
||||
api_rate_limit: {
|
||||
usage: 0,
|
||||
limit: -1,
|
||||
reset_date: 0,
|
||||
},
|
||||
})
|
||||
const result2 = parseCurrentPlan(data2)
|
||||
expect(result2.total.apiRateLimit).toBe(-1)
|
||||
})
|
||||
|
||||
it('should handle reset dates with milliseconds timestamp', () => {
|
||||
const futureDate = Date.now() + 86400000 // Tomorrow in ms
|
||||
const data = createMockPlanData({
|
||||
api_rate_limit: {
|
||||
usage: 100,
|
||||
limit: 5000,
|
||||
reset_date: futureDate,
|
||||
},
|
||||
})
|
||||
const result = parseCurrentPlan(data)
|
||||
|
||||
expect(result.reset.apiRateLimit).toBe(1)
|
||||
})
|
||||
|
||||
it('should handle reset dates with seconds timestamp', () => {
|
||||
const futureDate = Math.floor(Date.now() / 1000) + 86400 // Tomorrow in seconds
|
||||
const data = createMockPlanData({
|
||||
api_rate_limit: {
|
||||
usage: 100,
|
||||
limit: 5000,
|
||||
reset_date: futureDate,
|
||||
},
|
||||
})
|
||||
const result = parseCurrentPlan(data)
|
||||
|
||||
expect(result.reset.apiRateLimit).toBe(1)
|
||||
})
|
||||
|
||||
it('should handle reset dates in YYYYMMDD format', () => {
|
||||
const tomorrow = new Date()
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
const year = tomorrow.getFullYear()
|
||||
const month = String(tomorrow.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(tomorrow.getDate()).padStart(2, '0')
|
||||
const dateNumber = Number.parseInt(`${year}${month}${day}`, 10)
|
||||
|
||||
const data = createMockPlanData({
|
||||
api_rate_limit: {
|
||||
usage: 100,
|
||||
limit: 5000,
|
||||
reset_date: dateNumber,
|
||||
},
|
||||
})
|
||||
const result = parseCurrentPlan(data)
|
||||
|
||||
expect(result.reset.apiRateLimit).toBe(1)
|
||||
})
|
||||
|
||||
it('should return null for invalid reset dates', () => {
|
||||
const data = createMockPlanData({
|
||||
api_rate_limit: {
|
||||
usage: 100,
|
||||
limit: 5000,
|
||||
reset_date: 0,
|
||||
},
|
||||
})
|
||||
const result = parseCurrentPlan(data)
|
||||
expect(result.reset.apiRateLimit).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for negative reset dates', () => {
|
||||
const data = createMockPlanData({
|
||||
api_rate_limit: {
|
||||
usage: 100,
|
||||
limit: 5000,
|
||||
reset_date: -1,
|
||||
},
|
||||
})
|
||||
const result = parseCurrentPlan(data)
|
||||
expect(result.reset.apiRateLimit).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null when reset date is in the past', () => {
|
||||
const pastDate = Date.now() - 86400000 // Yesterday
|
||||
const data = createMockPlanData({
|
||||
api_rate_limit: {
|
||||
usage: 100,
|
||||
limit: 5000,
|
||||
reset_date: pastDate,
|
||||
},
|
||||
})
|
||||
const result = parseCurrentPlan(data)
|
||||
expect(result.reset.apiRateLimit).toBeNull()
|
||||
})
|
||||
|
||||
it('should return null for unrecognized date format', () => {
|
||||
const data = createMockPlanData({
|
||||
api_rate_limit: {
|
||||
usage: 100,
|
||||
limit: 5000,
|
||||
reset_date: 12345, // Unrecognized format
|
||||
},
|
||||
})
|
||||
const result = parseCurrentPlan(data)
|
||||
expect(result.reset.apiRateLimit).toBeNull()
|
||||
it('preserves unlimited rate limits in either API representation', () => {
|
||||
expect(parseRateLimit(0)).toBe(-1)
|
||||
expect(parseRateLimit(-1)).toBe(-1)
|
||||
expect(parseRateLimit(5000)).toBe(5000)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { CloudPlan, GetFeaturesResponse } from '@dify/contracts/api/console/features/types.gen'
|
||||
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
|
||||
import dayjs from 'dayjs'
|
||||
import { ALL_PLANS, NUM_INFINITE } from '@/app/components/billing/config'
|
||||
|
||||
@ -23,13 +23,14 @@ export const getPlanVectorSpaceLimitMB = (planType: CloudPlan): number => {
|
||||
return parseVectorSpaceToMB(ALL_PLANS[planType].vectorSpace)
|
||||
}
|
||||
|
||||
const parseLimit = (limit: number) => {
|
||||
// The API uses 0 for unlimited count quotas.
|
||||
export const parseLimit = (limit: number) => {
|
||||
if (limit === 0) return NUM_INFINITE
|
||||
|
||||
return limit
|
||||
}
|
||||
|
||||
const parseRateLimit = (limit: number) => {
|
||||
export const parseRateLimit = (limit: number) => {
|
||||
if (limit === 0 || limit === -1) return NUM_INFINITE
|
||||
|
||||
return limit
|
||||
@ -63,34 +64,3 @@ export const getResetInDaysFromDate = (resetDate: number) => {
|
||||
|
||||
return diff
|
||||
}
|
||||
|
||||
export const parseCurrentPlan = (data: GetFeaturesResponse) => {
|
||||
const planType = data.billing.subscription.plan
|
||||
const vectorSpaceLimit = getPlanVectorSpaceLimitMB(planType)
|
||||
|
||||
return {
|
||||
type: planType,
|
||||
usage: {
|
||||
vectorSpace: 0,
|
||||
buildApps: data.apps.size,
|
||||
teamMembers: data.members.size,
|
||||
annotatedResponse: data.annotation_quota_limit.size,
|
||||
documentsUploadQuota: data.documents_upload_quota.size,
|
||||
apiRateLimit: data.api_rate_limit.usage,
|
||||
triggerEvents: data.trigger_event.usage,
|
||||
},
|
||||
total: {
|
||||
vectorSpace: vectorSpaceLimit,
|
||||
buildApps: parseLimit(data.apps.limit),
|
||||
teamMembers: parseLimit(data.members.limit),
|
||||
annotatedResponse: parseLimit(data.annotation_quota_limit.limit),
|
||||
documentsUploadQuota: parseLimit(data.documents_upload_quota.limit),
|
||||
apiRateLimit: parseRateLimit(data.api_rate_limit.limit),
|
||||
triggerEvents: parseRateLimit(data.trigger_event.limit),
|
||||
},
|
||||
reset: {
|
||||
apiRateLimit: getResetInDaysFromDate(data.api_rate_limit.reset_date),
|
||||
triggerEvents: getResetInDaysFromDate(data.trigger_event.reset_date),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,7 +25,9 @@ const VectorSpaceFull: FC = () => {
|
||||
</div>
|
||||
<UpgradeBtn loc="knowledge-add-file" />
|
||||
</div>
|
||||
<VectorSpaceInfo className="pt-4" />
|
||||
<React.Suspense fallback={null}>
|
||||
<VectorSpaceInfo className="pt-4" />
|
||||
</React.Suspense>
|
||||
</div>
|
||||
</GridMask>
|
||||
)
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { queryOptions, useQuery } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { useAutoDisabledDocuments } from '@/service/knowledge/use-document'
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import AutoDisabledDocument from '../auto-disabled-document'
|
||||
|
||||
const { mockToastSuccess } = vi.hoisted(() => ({
|
||||
@ -89,6 +92,29 @@ describe('AutoDisabledDocument', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('enables the current document IDs after the initial query completes', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockUseAutoDisabledDocuments.mockImplementation(() =>
|
||||
useQuery(
|
||||
queryOptions({
|
||||
queryKey: ['disabled-documents'],
|
||||
queryFn: () => new Promise<AutoDisabledDocumentsResponse>(() => {}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const { wrapper, queryClient } = createConsoleQueryWrapper()
|
||||
render(<AutoDisabledDocument datasetId="dataset" />, { wrapper })
|
||||
expect(screen.queryByRole('button', { name: /enable/i })).not.toBeInTheDocument()
|
||||
await act(async () => {
|
||||
queryClient.setQueryData(['disabled-documents'], { document_ids: ['returned-document'] })
|
||||
})
|
||||
await user.click(await screen.findByRole('button', { name: /enable/i }))
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
datasetId: 'dataset',
|
||||
documentIds: ['returned-document'],
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should call enableDocument when action button is clicked', async () => {
|
||||
mockUseAutoDisabledDocuments.mockReturnValue(
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
import type { FC } from 'react'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
useAutoDisabledDocuments,
|
||||
@ -20,14 +19,14 @@ const AutoDisabledDocument: FC<Props> = ({ datasetId }) => {
|
||||
const { data, isLoading } = useAutoDisabledDocuments(datasetId)
|
||||
const invalidDisabledDocument = useInvalidDisabledDocument()
|
||||
const documentIds = data?.document_ids
|
||||
const hasDisabledDocument = documentIds && documentIds.length > 0
|
||||
const { mutateAsync: enableDocument } = useDocumentEnable()
|
||||
const handleEnableDocuments = useCallback(async () => {
|
||||
if (!documentIds?.length || isLoading) return null
|
||||
|
||||
const handleEnableDocuments = async () => {
|
||||
await enableDocument({ datasetId, documentIds })
|
||||
invalidDisabledDocument()
|
||||
toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' }))
|
||||
}, [])
|
||||
if (!hasDisabledDocument || isLoading) return null
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusWithAction
|
||||
|
||||
@ -3,7 +3,6 @@ import type { DocumentListResponse } from '@/models/datasets'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, screen } from '@testing-library/react'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { DataSourceType } from '@/models/datasets'
|
||||
import { useDocumentList } from '@/service/knowledge/use-document'
|
||||
import { createAccountProfileQueryClient } from '@/test/console/account-profile'
|
||||
@ -54,12 +53,6 @@ vi.mock('@/context/dataset-detail', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: vi.fn(() => ({
|
||||
plan: { type: 'professional' },
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/workspace-state', async () => {
|
||||
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
|
||||
@ -193,7 +186,6 @@ vi.mock('../components/documents-header', () => ({
|
||||
datasetId: string
|
||||
dataSourceType?: string
|
||||
embeddingAvailable: boolean
|
||||
isFreePlan: boolean
|
||||
statusFilterValue: string
|
||||
sortValue: string
|
||||
inputValue: string
|
||||
@ -719,16 +711,6 @@ describe('Documents', () => {
|
||||
|
||||
expect(screen.getByTestId('header-embedding-available')).toHaveTextContent('false')
|
||||
})
|
||||
|
||||
it('should handle free plan user', () => {
|
||||
vi.mocked(useProviderContext).mockReturnValueOnce({
|
||||
plan: { type: 'sandbox' },
|
||||
} as ReturnType<typeof useProviderContext>)
|
||||
|
||||
render(<Documents {...defaultProps} />)
|
||||
|
||||
expect(screen.getByTestId('documents-header')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Pagination', () => {
|
||||
|
||||
@ -36,7 +36,6 @@ describe('DocumentsHeader', () => {
|
||||
canManageMetadata: true,
|
||||
canAddDocument: true,
|
||||
canEditDocument: true,
|
||||
isFreePlan: false,
|
||||
statusFilterValue: 'all',
|
||||
sortValue: 'created_at' as SortType,
|
||||
inputValue: '',
|
||||
@ -104,16 +103,6 @@ describe('DocumentsHeader', () => {
|
||||
})
|
||||
|
||||
describe('AutoDisabledDocument', () => {
|
||||
it('should show AutoDisabledDocument when not free plan', () => {
|
||||
render(<DocumentsHeader {...defaultProps} isFreePlan={false} />)
|
||||
expect(screen.getByTestId('auto-disabled-document')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show AutoDisabledDocument when on free plan', () => {
|
||||
render(<DocumentsHeader {...defaultProps} isFreePlan={true} />)
|
||||
expect(screen.queryByTestId('auto-disabled-document')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show AutoDisabledDocument without document edit permission', () => {
|
||||
render(<DocumentsHeader {...defaultProps} canEditDocument={false} />)
|
||||
expect(screen.queryByTestId('auto-disabled-document')).not.toBeInTheDocument()
|
||||
|
||||
@ -32,7 +32,6 @@ type DocumentsHeaderProps = {
|
||||
canManageMetadata?: boolean
|
||||
canAddDocument?: boolean
|
||||
canEditDocument?: boolean
|
||||
isFreePlan: boolean
|
||||
|
||||
// Filter & sort
|
||||
statusFilterValue: string
|
||||
@ -66,7 +65,6 @@ const DocumentsHeader: FC<DocumentsHeaderProps> = ({
|
||||
canManageMetadata = false,
|
||||
canAddDocument = false,
|
||||
canEditDocument = false,
|
||||
isFreePlan,
|
||||
statusFilterValue,
|
||||
sortValue,
|
||||
inputValue,
|
||||
@ -174,7 +172,7 @@ const DocumentsHeader: FC<DocumentsHeaderProps> = ({
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex h-8! items-center justify-center gap-2">
|
||||
{!isFreePlan && canEditDocument && <AutoDisabledDocument datasetId={datasetId} />}
|
||||
{canEditDocument && <AutoDisabledDocument datasetId={datasetId} />}
|
||||
{canEditDocument && <IndexFailed datasetId={datasetId} />}
|
||||
{!embeddingAvailable && (
|
||||
<StatusWithAction
|
||||
|
||||
@ -7,7 +7,6 @@ import { useCallback } from 'react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { userProfileQueryOptions } from '@/features/account-profile/client'
|
||||
import { DataSourceType } from '@/models/datasets'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
@ -35,8 +34,6 @@ const FORCED_POLLING_STATUSES = new Set(['queuing', 'indexing', 'paused'])
|
||||
|
||||
const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
|
||||
const router = useRouter()
|
||||
const { plan } = useProviderContext()
|
||||
const isFreePlan = plan.type === 'sandbox'
|
||||
|
||||
const dataset = useDatasetDetailContextWithSelector((s) => s.dataset)
|
||||
const { data: currentUserId } = useSuspenseQuery({
|
||||
@ -185,7 +182,6 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
|
||||
canManageMetadata={datasetACLCapabilities.canEdit}
|
||||
canAddDocument={datasetACLCapabilities.canUse}
|
||||
canEditDocument={datasetACLCapabilities.canEdit}
|
||||
isFreePlan={isFreePlan}
|
||||
statusFilterValue={statusFilterValue}
|
||||
sortValue={sortValue}
|
||||
inputValue={inputValue}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
|
||||
import type { ReactElement } from 'react'
|
||||
import type { CreateAppModalProps } from '../index'
|
||||
import type { UsagePlanInfo } from '@/app/components/billing/type'
|
||||
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
@ -43,20 +42,10 @@ vi.mock('@/next/navigation', () => ({
|
||||
useParams: () => ({}),
|
||||
}))
|
||||
|
||||
const createPlanInfo = (buildApps: number): UsagePlanInfo => ({
|
||||
vectorSpace: 0,
|
||||
buildApps,
|
||||
teamMembers: 0,
|
||||
annotatedResponse: 0,
|
||||
documentsUploadQuota: 0,
|
||||
apiRateLimit: 0,
|
||||
triggerEvents: 0,
|
||||
})
|
||||
|
||||
let deploymentEdition: 'CLOUD' | 'COMMUNITY' = 'COMMUNITY'
|
||||
let mockPlanType: CloudPlan = 'team'
|
||||
let mockUsagePlanInfo: UsagePlanInfo = createPlanInfo(1)
|
||||
let mockTotalPlanInfo: UsagePlanInfo = createPlanInfo(10)
|
||||
let mockAppCount = 1
|
||||
const mockAppLimit = 10
|
||||
|
||||
type ConfirmPayload = Parameters<CreateAppModalProps['onConfirm']>[0]
|
||||
|
||||
@ -107,7 +96,7 @@ function render(ui: ReactElement) {
|
||||
systemFeatures: { deployment_edition: deploymentEdition },
|
||||
features: {
|
||||
billing: { subscription: { plan: mockPlanType } },
|
||||
apps: { size: mockUsagePlanInfo.buildApps, limit: mockTotalPlanInfo.buildApps },
|
||||
apps: { size: mockAppCount, limit: mockAppLimit },
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -117,8 +106,7 @@ describe('CreateAppModal', () => {
|
||||
vi.clearAllMocks()
|
||||
deploymentEdition = 'COMMUNITY'
|
||||
mockPlanType = 'team'
|
||||
mockUsagePlanInfo = createPlanInfo(1)
|
||||
mockTotalPlanInfo = createPlanInfo(10)
|
||||
mockAppCount = 1
|
||||
hotkeyMocks.handlers.clear()
|
||||
})
|
||||
|
||||
@ -222,8 +210,7 @@ describe('CreateAppModal', () => {
|
||||
it('should show AppsFull and disable create when apps quota is reached', async () => {
|
||||
deploymentEdition = 'CLOUD'
|
||||
mockPlanType = 'team'
|
||||
mockUsagePlanInfo = createPlanInfo(10)
|
||||
mockTotalPlanInfo = createPlanInfo(10)
|
||||
mockAppCount = 10
|
||||
|
||||
await setup({ isEditModal: false })
|
||||
|
||||
@ -234,8 +221,7 @@ describe('CreateAppModal', () => {
|
||||
it('should allow saving when apps quota is reached in edit mode', async () => {
|
||||
deploymentEdition = 'CLOUD'
|
||||
mockPlanType = 'team'
|
||||
mockUsagePlanInfo = createPlanInfo(10)
|
||||
mockTotalPlanInfo = createPlanInfo(10)
|
||||
mockAppCount = 10
|
||||
|
||||
await setup({ isEditModal: true })
|
||||
|
||||
@ -280,8 +266,7 @@ describe('CreateAppModal', () => {
|
||||
it('should not submit when apps quota is reached in create mode', async () => {
|
||||
deploymentEdition = 'CLOUD'
|
||||
mockPlanType = 'team'
|
||||
mockUsagePlanInfo = createPlanInfo(10)
|
||||
mockTotalPlanInfo = createPlanInfo(10)
|
||||
mockAppCount = 10
|
||||
|
||||
const { onConfirm, onHide } = await setup({ isEditModal: false })
|
||||
|
||||
@ -297,8 +282,7 @@ describe('CreateAppModal', () => {
|
||||
it('should submit when apps quota is reached in edit mode', async () => {
|
||||
deploymentEdition = 'CLOUD'
|
||||
mockPlanType = 'team'
|
||||
mockUsagePlanInfo = createPlanInfo(10)
|
||||
mockTotalPlanInfo = createPlanInfo(10)
|
||||
mockAppCount = 10
|
||||
|
||||
const { onConfirm, onHide } = await setup({ isEditModal: true })
|
||||
|
||||
|
||||
@ -8,29 +8,19 @@ import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
|
||||
import { getDocDownloadUrl } from '@/service/common'
|
||||
import { seedFeatures } from '@/test/console/query-data'
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
import Compliance from '../compliance'
|
||||
|
||||
vi.mock('@/context/provider-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/provider-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useProviderContext: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/modal-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/modal-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useModalContext: vi.fn(),
|
||||
}
|
||||
return { ...actual, useModalContext: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
getDocDownloadUrl: vi.fn(),
|
||||
vi.mock('@/service/common', () => ({ getDocDownloadUrl: vi.fn() }))
|
||||
vi.mock('@/service/base', () => ({
|
||||
request: vi.fn(() => new Promise(() => {})),
|
||||
sseGeneratorPost: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
@ -55,17 +45,11 @@ describe('Compliance', () => {
|
||||
toastErrorSpy.mockClear()
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
queries: { retry: false, staleTime: Infinity },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
...baseProviderContextValue,
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
type: 'sandbox',
|
||||
},
|
||||
})
|
||||
seedFeatures(queryClient, { billing: { subscription: { plan: 'sandbox' } } })
|
||||
vi.mocked(useModalContext).mockReturnValue({
|
||||
setShowPricingModal: mockSetShowPricingModal,
|
||||
} as unknown as ModalContextState)
|
||||
@ -95,6 +79,23 @@ describe('Compliance', () => {
|
||||
return screen.getByText(label).closest('[role="menuitem"]')
|
||||
}
|
||||
|
||||
it('keeps GDPR downloadable while plan-dependent documents wait for features', async () => {
|
||||
queryClient.clear()
|
||||
vi.mocked(getDocDownloadUrl).mockResolvedValue({ url: 'https://example.com/gdpr.pdf' })
|
||||
openMenuAndRender()
|
||||
expect(screen.queryByText('common.compliance.soc2Type1')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.compliance.soc2Type2')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.compliance.iso27001')).not.toBeInTheDocument()
|
||||
expect(getComplianceMenuItem('common.compliance.gdpr')).not.toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
)
|
||||
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('common.compliance.gdpr'))
|
||||
await waitFor(() => expect(getDocDownloadUrl).toHaveBeenCalledWith('GDPR'))
|
||||
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render compliance menu trigger', () => {
|
||||
// Act
|
||||
@ -130,13 +131,7 @@ describe('Compliance', () => {
|
||||
|
||||
it('should show Download button for plan that allows it', () => {
|
||||
// Arrange
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
...baseProviderContextValue,
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
type: 'team',
|
||||
},
|
||||
})
|
||||
seedFeatures(queryClient, { billing: { subscription: { plan: 'team' } } })
|
||||
|
||||
// Act
|
||||
openMenuAndRender()
|
||||
@ -154,13 +149,7 @@ describe('Compliance', () => {
|
||||
// Arrange
|
||||
const mockUrl = 'http://example.com/doc.pdf'
|
||||
vi.mocked(getDocDownloadUrl).mockResolvedValue({ url: mockUrl })
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
...baseProviderContextValue,
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
type: 'team',
|
||||
},
|
||||
})
|
||||
seedFeatures(queryClient, { billing: { subscription: { plan: 'team' } } })
|
||||
|
||||
// Act
|
||||
openMenuAndRender()
|
||||
@ -178,13 +167,7 @@ describe('Compliance', () => {
|
||||
it('should handle download mutation error', async () => {
|
||||
// Arrange
|
||||
vi.mocked(getDocDownloadUrl).mockRejectedValue(new Error('Download failed'))
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
...baseProviderContextValue,
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
type: 'team',
|
||||
},
|
||||
})
|
||||
seedFeatures(queryClient, { billing: { subscription: { plan: 'team' } } })
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
// Act
|
||||
@ -213,13 +196,7 @@ describe('Compliance', () => {
|
||||
|
||||
it('should handle upgrade click on badge for non-sandbox plan', () => {
|
||||
// Arrange
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
...baseProviderContextValue,
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
type: 'professional',
|
||||
},
|
||||
})
|
||||
seedFeatures(queryClient, { billing: { subscription: { plan: 'professional' } } })
|
||||
|
||||
// Act
|
||||
openMenuAndRender()
|
||||
@ -241,13 +218,7 @@ describe('Compliance', () => {
|
||||
resolveDownload = resolve
|
||||
}),
|
||||
)
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
...baseProviderContextValue,
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
type: 'team',
|
||||
},
|
||||
})
|
||||
seedFeatures(queryClient, { billing: { subscription: { plan: 'team' } } })
|
||||
|
||||
// Act
|
||||
openMenuAndRender()
|
||||
@ -279,13 +250,7 @@ describe('Compliance', () => {
|
||||
resolveDownload = resolve
|
||||
}),
|
||||
)
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
...baseProviderContextValue,
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
type: 'team',
|
||||
},
|
||||
})
|
||||
seedFeatures(queryClient, { billing: { subscription: { plan: 'team' } } })
|
||||
|
||||
openMenuAndRender()
|
||||
const menuItem = getComplianceMenuItem('common.compliance.soc2Type1')
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -19,7 +19,7 @@ import {
|
||||
settingsQueryParser,
|
||||
} from '@/app/components/header/account-setting/query-params'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { getDocDownloadUrl } from '@/service/common'
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
import Gdpr from '../../base/icons/src/public/common/Gdpr'
|
||||
@ -101,10 +101,15 @@ type ComplianceDocRowItemProps = {
|
||||
|
||||
function ComplianceDocRowItem({ icon, label, docName }: ComplianceDocRowItemProps) {
|
||||
const { t } = useTranslation()
|
||||
const { plan } = useProviderContext()
|
||||
const { data: plan } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: docName !== DocName.GDPR,
|
||||
select: (data) => data.billing.subscription.plan,
|
||||
}),
|
||||
)
|
||||
const { setShowPricingModal } = useModalContext()
|
||||
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
|
||||
const isFreePlan = plan.type === 'sandbox'
|
||||
const isFreePlan = plan === 'sandbox'
|
||||
|
||||
const { isPending, mutate: downloadCompliance } = useMutation({
|
||||
mutationKey: ['downloadCompliance', docName],
|
||||
@ -127,7 +132,9 @@ function ComplianceDocRowItem({ icon, label, docName }: ComplianceDocRowItemProp
|
||||
[DocName.GDPR]: ['team', 'professional', 'sandbox'],
|
||||
}
|
||||
|
||||
const isCurrentPlanCanDownload = whichPlanCanDownloadCompliance[docName].includes(plan.type)
|
||||
const isCurrentPlanCanDownload =
|
||||
docName === DocName.GDPR ||
|
||||
(plan !== undefined && whichPlanCanDownloadCompliance[docName].includes(plan))
|
||||
|
||||
const handleSelect = useCallback(() => {
|
||||
if (isCurrentPlanCanDownload) {
|
||||
@ -153,6 +160,8 @@ function ComplianceDocRowItem({ icon, label, docName }: ComplianceDocRowItemProp
|
||||
}
|
||||
const labelTitle = typeof label === 'string' ? label : undefined
|
||||
|
||||
if (docName !== DocName.GDPR && plan === undefined) return null
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className="h-10 justify-between py-1 pr-2 pl-1"
|
||||
@ -167,7 +176,7 @@ function ComplianceDocRowItem({ icon, label, docName }: ComplianceDocRowItemProp
|
||||
<ComplianceDocActionVisual
|
||||
isCurrentPlanCanDownload={isCurrentPlanCanDownload}
|
||||
isPending={isPending}
|
||||
tooltipText={upgradeTooltip[plan.type]}
|
||||
tooltipText={plan ? upgradeTooltip[plan] : ''}
|
||||
downloadText={t(($) => $['operation.download'], { ns: 'common' })}
|
||||
upgradeText={t(($) => $['upgradeBtn.encourageShort'], { ns: 'billing' })}
|
||||
/>
|
||||
|
||||
@ -1,22 +1,23 @@
|
||||
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
|
||||
import type { ModelItem, ModelProvider } from '../../declarations'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { disableModel, enableModel } from '@/service/common'
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { render } from '@/test/console/render'
|
||||
import { ModelStatusEnum } from '../../declarations'
|
||||
import ModelListItem from '../model-list-item'
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
let mockModelLoadBalancingEnabled = false
|
||||
let mockPlanType: string = 'pro'
|
||||
let mockPlanType: CloudPlan = 'professional'
|
||||
let mockWorkspacePermissionKeys: string[] = ['plugin.model_config']
|
||||
|
||||
function createWrapper(deploymentEdition: 'CLOUD' | 'COMMUNITY' = 'CLOUD') {
|
||||
return createConsoleQueryWrapper({
|
||||
systemFeatures: { deployment_edition: deploymentEdition },
|
||||
features: { billing: { subscription: { plan: mockPlanType } } },
|
||||
}).wrapper
|
||||
}
|
||||
|
||||
vi.mock('@/context/permission-state', async () => {
|
||||
const { createPermissionStateModuleMock } = await import('@/test/console/state-fixture')
|
||||
return createPermissionStateModuleMock(() => ({
|
||||
@ -25,9 +26,6 @@ vi.mock('@/context/permission-state', async () => {
|
||||
})
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
plan: { type: mockPlanType },
|
||||
}),
|
||||
useProviderContextSelector: () => mockModelLoadBalancingEnabled,
|
||||
}))
|
||||
|
||||
@ -78,10 +76,17 @@ describe('ModelListItem', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockModelLoadBalancingEnabled = false
|
||||
mockPlanType = 'pro'
|
||||
mockPlanType = 'professional'
|
||||
mockWorkspacePermissionKeys = ['plugin.model_config']
|
||||
})
|
||||
|
||||
it('keeps model configuration available outside Cloud without a Sandbox placeholder', () => {
|
||||
render(<ModelListItem model={mockModel} provider={mockProvider} isConfigurable />, {
|
||||
wrapper: createWrapper('COMMUNITY'),
|
||||
})
|
||||
expect(screen.getByRole('button', { name: 'modify load balancing' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render model item with icon and name', () => {
|
||||
render(<ModelListItem model={mockModel} provider={mockProvider} isConfigurable={false} />, {
|
||||
wrapper: createWrapper(),
|
||||
@ -247,7 +252,7 @@ describe('ModelListItem', () => {
|
||||
it('should hide ConfigModel for non-sandbox plan without load balancing enabled', () => {
|
||||
// Arrange - set plan type to non-sandbox and keep load balancing disabled
|
||||
mockModelLoadBalancingEnabled = false
|
||||
mockPlanType = 'pro'
|
||||
mockPlanType = 'professional'
|
||||
|
||||
// Act
|
||||
render(<ModelListItem model={mockModel} provider={mockProvider} isConfigurable={false} />, {
|
||||
|
||||
@ -3,7 +3,7 @@ import type { ModelItem, ModelProvider } from '../declarations'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { memo, useCallback } from 'react'
|
||||
@ -11,7 +11,8 @@ import { useTranslation } from 'react-i18next'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import { Balance } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import { useProviderContext, useProviderContextSelector } from '@/context/provider-context'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
import { deploymentEditionAtom } from '@/features/system-features/state'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { disableModel, enableModel } from '@/service/common'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
@ -41,7 +42,13 @@ const ModelListItem = ({
|
||||
onModifyLoadBalancing,
|
||||
}: ModelListItemProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { plan } = useProviderContext()
|
||||
const deploymentEdition = useAtomValue(deploymentEditionAtom)
|
||||
const { data: plan } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD',
|
||||
select: (features) => features.billing.subscription.plan,
|
||||
}),
|
||||
)
|
||||
const modelLoadBalancingEnabled = useProviderContextSelector(
|
||||
(state) => state.modelLoadBalancingEnabled,
|
||||
)
|
||||
@ -131,7 +138,7 @@ const ModelListItem = ({
|
||||
</Badge>
|
||||
)}
|
||||
{canConfigureModels &&
|
||||
(modelLoadBalancingEnabled || plan.type === 'sandbox') &&
|
||||
(deploymentEdition !== 'CLOUD' || modelLoadBalancingEnabled || plan === 'sandbox') &&
|
||||
!model.deprecated &&
|
||||
[ModelStatusEnum.active, ModelStatusEnum.disabled].includes(model.status) && (
|
||||
<ConfigModel
|
||||
|
||||
@ -657,7 +657,6 @@ describe('MainNav', () => {
|
||||
}
|
||||
;(useProviderContext as Mock).mockReturnValue({
|
||||
enableEducationPlan: false,
|
||||
plan: { type: 'sandbox' },
|
||||
} as ProviderContextState)
|
||||
;(useModalContext as Mock).mockReturnValue({
|
||||
setShowPricingModal: mockSetShowPricingModal,
|
||||
@ -835,7 +834,6 @@ describe('MainNav', () => {
|
||||
it('shows the user education badge in the account popup without adding the workspace plan there', async () => {
|
||||
;(useProviderContext as Mock).mockReturnValue({
|
||||
enableEducationPlan: true,
|
||||
plan: { type: 'sandbox' },
|
||||
} as ProviderContextState)
|
||||
|
||||
renderMainNav(defaultMainNavSystemFeatures, {
|
||||
|
||||
@ -178,7 +178,6 @@ describe('WorkspaceCard', () => {
|
||||
mockCurrentWorkspaceQuery()
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
enableEducationPlan: false,
|
||||
plan: { type: 'sandbox' },
|
||||
} as ProviderContextState)
|
||||
mockWorkspacePermissionKeys(['workspace.member.manage'])
|
||||
vi.mocked(useModalContext).mockReturnValue({
|
||||
@ -343,7 +342,6 @@ describe('WorkspaceCard', () => {
|
||||
})
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
enableEducationPlan: false,
|
||||
plan: { type: 'sandbox' },
|
||||
} as ProviderContextState)
|
||||
renderWorkspaceCard({ systemFeatures: { deployment_edition: 'CLOUD' } })
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import type { ModalContextState } from '@/context/modal-context'
|
||||
import type { ProviderContextState } from '@/context/provider-context'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
@ -35,11 +34,6 @@ vi.mock('@/context/modal-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockUseProviderContext = vi.fn()
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockUseProviderContext(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/context/i18n')>('@/context/i18n')
|
||||
return {
|
||||
@ -62,13 +56,6 @@ describe('EditCustomCollectionModal', () => {
|
||||
parameters_schema: [],
|
||||
schema_type: 'openapi',
|
||||
})
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
plan: {
|
||||
type: 'sandbox',
|
||||
},
|
||||
|
||||
webappCopyrightEnabled: true,
|
||||
} as ProviderContextState)
|
||||
})
|
||||
|
||||
const renderModal = (props?: {
|
||||
|
||||
@ -96,6 +96,7 @@ vi.mock('@/config', async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
ZENDESK_FIELD_IDS: {
|
||||
PLAN: '',
|
||||
ENVIRONMENT: 'environment-field',
|
||||
VERSION: 'version-field',
|
||||
EMAIL: 'email-field',
|
||||
@ -113,6 +114,15 @@ vi.mock('@/features/account-profile/client', () => ({
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
features: {
|
||||
get: {
|
||||
queryOptions: (options: object) => ({
|
||||
queryKey: ['features'],
|
||||
queryFn: () => new Promise(() => {}),
|
||||
...options,
|
||||
}),
|
||||
},
|
||||
},
|
||||
systemFeatures: {
|
||||
get: {
|
||||
queryOptions: () => ({
|
||||
@ -514,6 +524,42 @@ describe('Console bootstrap', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('syncs the actual plan only after features arrive and follows plan changes', async () => {
|
||||
ZENDESK_FIELD_IDS.PLAN = 'plan-field'
|
||||
try {
|
||||
const { queryClient } = renderConsoleBootstrap()
|
||||
await waitFor(() => expect(zendeskRuntime.setConversationFields).toHaveBeenCalled())
|
||||
expect(
|
||||
vi
|
||||
.mocked(zendeskRuntime.setConversationFields)
|
||||
.mock.calls.flatMap(([fields]) => fields)
|
||||
.some((field) => field.id === 'plan-field'),
|
||||
).toBe(false)
|
||||
act(() =>
|
||||
queryClient.setQueryData(['features'], {
|
||||
billing: { subscription: { plan: 'professional' } },
|
||||
}),
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(zendeskRuntime.setConversationFields).toHaveBeenCalledWith(
|
||||
[{ id: 'plan-field', value: 'professional-plan' }],
|
||||
'CLOUD',
|
||||
),
|
||||
)
|
||||
act(() =>
|
||||
queryClient.setQueryData(['features'], { billing: { subscription: { plan: 'team' } } }),
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(zendeskRuntime.setConversationFields).toHaveBeenCalledWith(
|
||||
[{ id: 'plan-field', value: 'team-plan' }],
|
||||
'CLOUD',
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
ZENDESK_FIELD_IDS.PLAN = ''
|
||||
}
|
||||
})
|
||||
|
||||
it('should not sync Zendesk fields outside cloud deployments', async () => {
|
||||
mockSystemFeaturesState.data = createSystemFeaturesFixture({
|
||||
deployment_edition: 'COMMUNITY',
|
||||
|
||||
@ -2,23 +2,12 @@
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useEffect } from 'react'
|
||||
import { zendeskRuntime } from '@/app/components/base/zendesk/runtime'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import { parseCurrentPlan } from '@/app/components/billing/utils'
|
||||
import {
|
||||
ModelStatusEnum,
|
||||
ModelTypeEnum,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { ZENDESK_FIELD_IDS } from '@/config'
|
||||
import { deploymentEditionAtom } from '@/features/system-features/state'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import {
|
||||
commonQueryKeys,
|
||||
useModelListByType,
|
||||
useSupportRetrievalMethods,
|
||||
} from '@/service/use-common'
|
||||
import { commonQueryKeys, useModelListByType } from '@/service/use-common'
|
||||
import { ProviderContext } from './provider-context'
|
||||
|
||||
type ProviderContextProviderProps = {
|
||||
@ -26,7 +15,6 @@ type ProviderContextProviderProps = {
|
||||
}
|
||||
|
||||
export const ProviderContextProvider = ({ children }: ProviderContextProviderProps) => {
|
||||
const deploymentEdition = useAtomValue(deploymentEditionAtom)
|
||||
const queryClient = useQueryClient()
|
||||
const featuresQuery = useQuery(consoleQuery.features.get.queryOptions())
|
||||
const {
|
||||
@ -35,15 +23,12 @@ export const ProviderContextProvider = ({ children }: ProviderContextProviderPro
|
||||
isSuccess: isSuccessModelProviders,
|
||||
} = useQuery(consoleQuery.workspaces.current.modelProviders.summary.get.queryOptions())
|
||||
const { data: textGenerationModelList } = useModelListByType(ModelTypeEnum.textGeneration)
|
||||
const { data: supportRetrievalMethods } = useSupportRetrievalMethods()
|
||||
|
||||
const features = featuresQuery.data
|
||||
const plan = deploymentEdition === 'CLOUD' && features ? parseCurrentPlan(features) : defaultPlan
|
||||
const enableEducationPlan = features?.education.enabled ?? false
|
||||
const enableSkill = features?.enable_skill ?? false
|
||||
const enableReplaceWebAppLogo = features?.can_replace_logo ?? false
|
||||
const modelLoadBalancingEnabled = features?.model_load_balancing_enabled ?? false
|
||||
const webappCopyrightEnabled = features?.webapp_copyright_enabled ?? false
|
||||
const isAllowTransferWorkspace = features?.is_allow_transfer_workspace ?? false
|
||||
const isAllowPublishAsCustomKnowledgePipelineTemplate =
|
||||
features?.knowledge_pipeline.publish_enabled ?? false
|
||||
@ -57,22 +42,6 @@ export const ProviderContextProvider = ({ children }: ProviderContextProviderPro
|
||||
queryClient.invalidateQueries({ queryKey: commonQueryKeys.modelProviderDetails }),
|
||||
]).then(() => undefined)
|
||||
|
||||
// #region Zendesk conversation fields
|
||||
useEffect(() => {
|
||||
if (ZENDESK_FIELD_IDS.PLAN && plan.type) {
|
||||
zendeskRuntime.setConversationFields(
|
||||
[
|
||||
{
|
||||
id: ZENDESK_FIELD_IDS.PLAN,
|
||||
value: `${plan.type}-plan`,
|
||||
},
|
||||
],
|
||||
deploymentEdition,
|
||||
)
|
||||
}
|
||||
}, [deploymentEdition, plan.type])
|
||||
// #endregion Zendesk conversation fields
|
||||
|
||||
return (
|
||||
<ProviderContext.Provider
|
||||
value={{
|
||||
@ -85,13 +54,10 @@ export const ProviderContextProvider = ({ children }: ProviderContextProviderPro
|
||||
isAPIKeySet: !!textGenerationModelList?.data?.some(
|
||||
(model) => model.status === ModelStatusEnum.active,
|
||||
),
|
||||
supportRetrievalMethods: supportRetrievalMethods?.retrieval_method || [],
|
||||
plan,
|
||||
enableSkill,
|
||||
enableReplaceWebAppLogo,
|
||||
modelLoadBalancingEnabled,
|
||||
enableEducationPlan,
|
||||
webappCopyrightEnabled,
|
||||
isAllowTransferWorkspace,
|
||||
isAllowPublishAsCustomKnowledgePipelineTemplate,
|
||||
humanInputEmailDeliveryEnabled,
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
|
||||
import type {
|
||||
ModelProviderPluginSummaryResponse,
|
||||
ModelProviderSummaryResponse,
|
||||
} from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { UsagePlanInfo, UsageResetInfo } from '@/app/components/billing/type'
|
||||
import type { Model } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { RETRIEVE_METHOD } from '@/types/app'
|
||||
import { createContext, useContext, useContextSelector } from 'use-context-selector'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
|
||||
export type ProviderContextState = {
|
||||
modelProviders: ModelProviderSummaryResponse[]
|
||||
@ -18,19 +14,11 @@ export type ProviderContextState = {
|
||||
isSuccessModelProviders: boolean
|
||||
refreshModelProviders: () => Promise<void>
|
||||
textGenerationModelList: Model[]
|
||||
supportRetrievalMethods: RETRIEVE_METHOD[]
|
||||
isAPIKeySet: boolean
|
||||
plan: {
|
||||
type: CloudPlan
|
||||
usage: UsagePlanInfo
|
||||
total: UsagePlanInfo
|
||||
reset: UsageResetInfo
|
||||
}
|
||||
enableSkill: boolean
|
||||
enableReplaceWebAppLogo: boolean
|
||||
modelLoadBalancingEnabled: boolean
|
||||
enableEducationPlan: boolean
|
||||
webappCopyrightEnabled: boolean
|
||||
isAllowTransferWorkspace: boolean
|
||||
isAllowPublishAsCustomKnowledgePipelineTemplate: boolean
|
||||
humanInputEmailDeliveryEnabled: boolean
|
||||
@ -43,14 +31,11 @@ export const baseProviderContextValue: ProviderContextState = {
|
||||
isSuccessModelProviders: false,
|
||||
refreshModelProviders: async () => {},
|
||||
textGenerationModelList: [],
|
||||
supportRetrievalMethods: [],
|
||||
isAPIKeySet: true,
|
||||
plan: defaultPlan,
|
||||
enableSkill: false,
|
||||
enableReplaceWebAppLogo: false,
|
||||
modelLoadBalancingEnabled: false,
|
||||
enableEducationPlan: false,
|
||||
webappCopyrightEnabled: false,
|
||||
isAllowTransferWorkspace: false,
|
||||
isAllowPublishAsCustomKnowledgePipelineTemplate: false,
|
||||
humanInputEmailDeliveryEnabled: false,
|
||||
|
||||
@ -15,7 +15,6 @@ import type {
|
||||
StructuredOutputRulesRequestBody,
|
||||
StructuredOutputRulesResponse,
|
||||
} from '@/models/common'
|
||||
import type { RETRIEVE_METHOD } from '@/types/app'
|
||||
import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { discardRegistrationSessionState } from '@/app/components/base/amplitude/registration-session-state'
|
||||
// oxlint-disable-next-line no-restricted-imports
|
||||
@ -32,7 +31,6 @@ export const commonQueryKeys = {
|
||||
modelProviders: [NAME_SPACE, 'model-providers'] as const,
|
||||
modelProviderDetails: [NAME_SPACE, 'model-provider-details'] as const,
|
||||
defaultModel: (type: ModelTypeEnum) => [NAME_SPACE, 'default-model', type] as const,
|
||||
retrievalMethods: [NAME_SPACE, 'support-retrieval-methods'] as const,
|
||||
accountIntegrates: [NAME_SPACE, 'account-integrates'] as const,
|
||||
notionConnection: [NAME_SPACE, 'notion-connection'] as const,
|
||||
codeBasedExtensions: (module?: string) => [NAME_SPACE, 'code-based-extensions', module] as const,
|
||||
@ -227,13 +225,6 @@ export const useModelListByType = (type: ModelTypeEnum | ModelType, enabled = tr
|
||||
})
|
||||
}
|
||||
|
||||
export const useSupportRetrievalMethods = () => {
|
||||
return useQuery<{ retrieval_method: RETRIEVE_METHOD[] }>({
|
||||
queryKey: commonQueryKeys.retrievalMethods,
|
||||
queryFn: () => get<{ retrieval_method: RETRIEVE_METHOD[] }>('/datasets/retrieval-setting'),
|
||||
})
|
||||
}
|
||||
|
||||
export const useCodeBasedExtensions = (module: string) => {
|
||||
return useQuery<CodeBasedExtension>({
|
||||
queryKey: commonQueryKeys.codeBasedExtensions(module),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user