mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 02:43:49 +08:00
refactor(web): remove provider plan query flags (#41908)
This commit is contained in:
parent
3be0cabf0a
commit
5c0d3c4393
@ -4796,7 +4796,7 @@
|
||||
},
|
||||
"web/context/hooks/use-trigger-events-limit-modal.ts": {
|
||||
"eslint-react/set-state-in-effect": {
|
||||
"count": 3
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-globals": {
|
||||
"count": 2
|
||||
|
||||
@ -16,8 +16,6 @@ export const baseProviderContextValue: ProviderContextState = {
|
||||
supportRetrievalMethods: [],
|
||||
isAPIKeySet: true,
|
||||
plan: defaultPlan,
|
||||
isFetchedPlan: false,
|
||||
isFetchedPlanInfo: false,
|
||||
enableBilling: false,
|
||||
enableSkill: false,
|
||||
enableReplaceWebAppLogo: false,
|
||||
|
||||
@ -115,7 +115,6 @@ const setupProviderContext = (
|
||||
mockProviderCtx = {
|
||||
plan: createPlanData(planOverrides),
|
||||
enableBilling: true,
|
||||
isFetchedPlan: true,
|
||||
enableEducationPlan: false,
|
||||
...extra,
|
||||
}
|
||||
|
||||
@ -94,7 +94,6 @@ const setupContexts = (
|
||||
mockProviderCtx = {
|
||||
plan: createPlanData(planOverrides),
|
||||
enableBilling: true,
|
||||
isFetchedPlan: true,
|
||||
enableEducationPlan: false,
|
||||
...providerOverrides,
|
||||
}
|
||||
|
||||
@ -1,37 +1,37 @@
|
||||
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
|
||||
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
|
||||
import type { ReactElement } from 'react'
|
||||
import type { QueryParam } from '../index'
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import {
|
||||
createConsoleQueryClient,
|
||||
renderWithConsoleQuery,
|
||||
seedFeatures,
|
||||
} from '@/test/console/query-data'
|
||||
import Filter, { TIME_PERIOD_MAPPING } from '../filter'
|
||||
|
||||
let mockAnnotationsCountLoading = false
|
||||
let mockAnnotationsCountData: { count: number } | null = { count: 10 }
|
||||
const mockRuntime = vi.hoisted(() => ({
|
||||
deploymentEdition: 'CLOUD',
|
||||
enableBilling: true,
|
||||
isFetchedPlan: true,
|
||||
isFetchedPlanInfo: true,
|
||||
planType: 'professional',
|
||||
}))
|
||||
const scenario = {
|
||||
deploymentEdition: 'CLOUD' as DeploymentEdition,
|
||||
pending: false,
|
||||
planType: 'professional' as CloudPlan,
|
||||
}
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useSuspenseQuery: () => ({ data: mockRuntime.deploymentEdition }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/provider-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/provider-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useProviderContext: () => ({
|
||||
enableBilling: mockRuntime.enableBilling,
|
||||
isFetchedPlan: mockRuntime.isFetchedPlan,
|
||||
isFetchedPlanInfo: mockRuntime.isFetchedPlanInfo,
|
||||
plan: { type: mockRuntime.planType },
|
||||
}),
|
||||
}
|
||||
})
|
||||
const render = (ui: ReactElement) => {
|
||||
const queryClient = createConsoleQueryClient()
|
||||
if (scenario.pending) {
|
||||
void queryClient.query({
|
||||
queryKey: consoleQuery.features.get.queryKey(),
|
||||
queryFn: () => new Promise(() => {}),
|
||||
})
|
||||
} else seedFeatures(queryClient, { billing: { subscription: { plan: scenario.planType } } })
|
||||
return renderWithConsoleQuery(ui, {
|
||||
queryClient,
|
||||
systemFeatures: { deployment_edition: scenario.deploymentEdition },
|
||||
})
|
||||
}
|
||||
|
||||
vi.mock('@/service/use-log', () => ({
|
||||
useAnnotationsCount: () => ({
|
||||
@ -102,11 +102,9 @@ describe('Filter', () => {
|
||||
vi.clearAllMocks()
|
||||
mockAnnotationsCountLoading = false
|
||||
mockAnnotationsCountData = { count: 10 }
|
||||
mockRuntime.deploymentEdition = 'CLOUD'
|
||||
mockRuntime.enableBilling = true
|
||||
mockRuntime.isFetchedPlan = true
|
||||
mockRuntime.isFetchedPlanInfo = true
|
||||
mockRuntime.planType = 'professional'
|
||||
scenario.deploymentEdition = 'CLOUD'
|
||||
scenario.pending = false
|
||||
scenario.planType = 'professional'
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
@ -179,8 +177,8 @@ describe('Filter', () => {
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should only show supported periods for Cloud sandbox workspaces', () => {
|
||||
mockRuntime.deploymentEdition = 'CLOUD'
|
||||
mockRuntime.planType = 'sandbox'
|
||||
scenario.deploymentEdition = 'CLOUD'
|
||||
scenario.planType = 'sandbox'
|
||||
|
||||
render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
|
||||
|
||||
@ -194,11 +192,12 @@ describe('Filter', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should only show supported periods while the Cloud plan is pending', () => {
|
||||
mockRuntime.isFetchedPlan = false
|
||||
mockRuntime.isFetchedPlanInfo = false
|
||||
it('should keep periods restricted until the Cloud plan resolves, then follow cache updates', async () => {
|
||||
scenario.pending = true
|
||||
|
||||
render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
|
||||
const { queryClient } = render(
|
||||
<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open-options-1' }))
|
||||
|
||||
@ -208,36 +207,36 @@ describe('Filter', () => {
|
||||
expect.stringMatching(/(?:^|\.)filter\.period\.last7days(?=$|:)/),
|
||||
expect.stringMatching(/(?:^|\.)filter\.period\.last30days(?=$|:)/),
|
||||
])
|
||||
|
||||
act(() => {
|
||||
seedFeatures(queryClient, { billing: { subscription: { plan: 'professional' } } })
|
||||
})
|
||||
await waitFor(() => expect(periodOptions.getAllByRole('listitem')).toHaveLength(9))
|
||||
|
||||
act(() => {
|
||||
seedFeatures(queryClient, { billing: { subscription: { plan: 'sandbox' } } })
|
||||
})
|
||||
await waitFor(() => expect(periodOptions.getAllByRole('listitem')).toHaveLength(3))
|
||||
})
|
||||
|
||||
it('should keep all periods when Cloud billing is known to be disabled', () => {
|
||||
mockRuntime.enableBilling = false
|
||||
mockRuntime.isFetchedPlan = false
|
||||
mockRuntime.isFetchedPlanInfo = true
|
||||
it.each(['COMMUNITY', 'ENTERPRISE'] as const)(
|
||||
'should keep all periods for sandbox workspaces in %s',
|
||||
(edition) => {
|
||||
scenario.deploymentEdition = edition
|
||||
scenario.planType = 'sandbox'
|
||||
|
||||
render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
|
||||
render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open-options-1' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open-options-1' }))
|
||||
|
||||
const periodOptions = within(screen.getByRole('list', { name: 'options-1' }))
|
||||
expect(periodOptions.getAllByRole('listitem')).toHaveLength(9)
|
||||
})
|
||||
|
||||
it('should keep all periods for sandbox workspaces outside Cloud', () => {
|
||||
mockRuntime.deploymentEdition = 'COMMUNITY'
|
||||
mockRuntime.planType = 'sandbox'
|
||||
|
||||
render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open-options-1' }))
|
||||
|
||||
const periodOptions = within(screen.getByRole('list', { name: 'options-1' }))
|
||||
expect(periodOptions.getAllByRole('listitem')).toHaveLength(9)
|
||||
})
|
||||
const periodOptions = within(screen.getByRole('list', { name: 'options-1' }))
|
||||
expect(periodOptions.getAllByRole('listitem')).toHaveLength(9)
|
||||
},
|
||||
)
|
||||
|
||||
it('should reset the Cloud sandbox period to today when cleared', () => {
|
||||
mockRuntime.deploymentEdition = 'CLOUD'
|
||||
mockRuntime.planType = 'sandbox'
|
||||
scenario.deploymentEdition = 'CLOUD'
|
||||
scenario.planType = 'sandbox'
|
||||
|
||||
render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
|
||||
|
||||
|
||||
@ -2,22 +2,12 @@ import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
|
||||
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
|
||||
import { screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { createConsoleQueryWrapper, seedFeatures } from '@/test/console/query-data'
|
||||
import { render } from '@/test/console/render'
|
||||
import { RetentionUpgradeNotice } from '../retention-upgrade-notice'
|
||||
|
||||
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 {
|
||||
@ -26,46 +16,30 @@ vi.mock('@/context/modal-context', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
const mockUseProviderContext = vi.mocked(useProviderContext)
|
||||
const mockUseModalContext = vi.mocked(useModalContext)
|
||||
|
||||
describe('RetentionUpgradeNotice', () => {
|
||||
const setShowPricingModal = vi.fn()
|
||||
|
||||
function mockProvider({
|
||||
enableBilling = true,
|
||||
isFetchedPlan = true,
|
||||
isFetchedPlanInfo = true,
|
||||
planType = 'sandbox',
|
||||
}: {
|
||||
enableBilling?: boolean
|
||||
isFetchedPlan?: boolean
|
||||
isFetchedPlanInfo?: boolean
|
||||
planType?: CloudPlan
|
||||
} = {}) {
|
||||
mockUseProviderContext.mockReturnValue(
|
||||
createMockProviderContextValue({
|
||||
enableBilling,
|
||||
isFetchedPlan,
|
||||
isFetchedPlanInfo,
|
||||
plan: {
|
||||
...defaultPlan,
|
||||
type: planType,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function renderNotice(deploymentEdition: DeploymentEdition = 'CLOUD') {
|
||||
const { wrapper } = createConsoleQueryWrapper({
|
||||
function renderNotice(
|
||||
deploymentEdition: DeploymentEdition = 'CLOUD',
|
||||
plan: CloudPlan | null = 'sandbox',
|
||||
) {
|
||||
const { wrapper, queryClient } = createConsoleQueryWrapper({
|
||||
systemFeatures: { deployment_edition: deploymentEdition },
|
||||
})
|
||||
if (plan) seedFeatures(queryClient, { billing: { subscription: { plan } } })
|
||||
else {
|
||||
void queryClient.query({
|
||||
queryKey: consoleQuery.features.get.queryKey(),
|
||||
queryFn: () => new Promise(() => {}),
|
||||
})
|
||||
}
|
||||
return render(<RetentionUpgradeNotice />, { wrapper })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockProvider()
|
||||
mockUseModalContext.mockReturnValue({
|
||||
setShowPricingModal,
|
||||
} as unknown as ReturnType<typeof useModalContext>)
|
||||
@ -89,28 +63,26 @@ describe('RetentionUpgradeNotice', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'paid Cloud workspaces',
|
||||
provider: { planType: 'professional' },
|
||||
plan: 'professional',
|
||||
deploymentEdition: 'CLOUD',
|
||||
},
|
||||
{
|
||||
name: 'self-hosted sandbox workspaces',
|
||||
provider: { planType: 'sandbox' },
|
||||
plan: 'sandbox',
|
||||
deploymentEdition: 'COMMUNITY',
|
||||
},
|
||||
{
|
||||
name: 'workspaces without billing',
|
||||
provider: { enableBilling: false },
|
||||
deploymentEdition: 'CLOUD',
|
||||
name: 'Enterprise workspaces',
|
||||
plan: 'sandbox',
|
||||
deploymentEdition: 'ENTERPRISE',
|
||||
},
|
||||
{
|
||||
name: 'workspaces before plan loading completes',
|
||||
provider: { isFetchedPlan: false, isFetchedPlanInfo: false },
|
||||
plan: null,
|
||||
deploymentEdition: 'CLOUD',
|
||||
},
|
||||
] as const)('should not show guidance for $name', ({ provider, deploymentEdition }) => {
|
||||
mockProvider(provider)
|
||||
|
||||
renderNotice(deploymentEdition)
|
||||
] as const)('should not show guidance for $name', ({ plan, deploymentEdition }) => {
|
||||
renderNotice(deploymentEdition, plan)
|
||||
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
export const CLOUD_SANDBOX_TIME_PERIOD_KEYS = new Set(['1', '2', '3'])
|
||||
export const CLOUD_SANDBOX_CLEARED_TIME_PERIOD = '1'
|
||||
@ -42,12 +42,15 @@ export function useCloudSandboxPlanStatus(): CloudSandboxPlanState {
|
||||
...systemFeaturesQueryOptions(),
|
||||
select: ({ deployment_edition }) => deployment_edition,
|
||||
})
|
||||
const { enableBilling, isFetchedPlan, isFetchedPlanInfo, plan } = useProviderContext()
|
||||
const { data: plan } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD',
|
||||
select: (features) => features.billing.subscription.plan,
|
||||
}),
|
||||
)
|
||||
|
||||
if (deploymentEdition !== 'CLOUD') return 'unrestricted'
|
||||
if (!isFetchedPlanInfo) return 'pending'
|
||||
if (!enableBilling) return 'unrestricted'
|
||||
if (!isFetchedPlan) return 'pending'
|
||||
if (!plan) return 'pending'
|
||||
|
||||
return plan.type === 'sandbox' ? 'sandbox' : 'unrestricted'
|
||||
return plan === 'sandbox' ? 'sandbox' : 'unrestricted'
|
||||
}
|
||||
|
||||
@ -46,8 +46,6 @@ const defaultProviderContext = {
|
||||
supportRetrievalMethods: [],
|
||||
isAPIKeySet: false,
|
||||
plan: defaultPlan,
|
||||
isFetchedPlan: false,
|
||||
isFetchedPlanInfo: false,
|
||||
enableBilling: false,
|
||||
enableSkill: false,
|
||||
enableReplaceWebAppLogo: false,
|
||||
|
||||
@ -7,44 +7,37 @@
|
||||
* - Keyword search
|
||||
*/
|
||||
|
||||
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
|
||||
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
|
||||
import type { ReactElement } from 'react'
|
||||
import type { QueryParam } from '../index'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
createConsoleQueryClient,
|
||||
renderWithConsoleQuery,
|
||||
seedFeatures,
|
||||
} from '@/test/console/query-data'
|
||||
import Filter, { TIME_PERIOD_MAPPING } from '../filter'
|
||||
|
||||
// ============================================================================
|
||||
// Mocks
|
||||
// ============================================================================
|
||||
|
||||
const mockRuntime = vi.hoisted(() => ({
|
||||
deploymentEdition: 'CLOUD',
|
||||
enableBilling: true,
|
||||
isFetchedPlan: true,
|
||||
isFetchedPlanInfo: true,
|
||||
planType: 'professional',
|
||||
}))
|
||||
const scenario = {
|
||||
deploymentEdition: 'CLOUD' as DeploymentEdition,
|
||||
planType: 'professional' as CloudPlan,
|
||||
}
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useSuspenseQuery: () => ({ data: mockRuntime.deploymentEdition }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/provider-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/provider-context')>()
|
||||
return {
|
||||
...actual,
|
||||
useProviderContext: () => ({
|
||||
enableBilling: mockRuntime.enableBilling,
|
||||
isFetchedPlan: mockRuntime.isFetchedPlan,
|
||||
isFetchedPlanInfo: mockRuntime.isFetchedPlanInfo,
|
||||
plan: { type: mockRuntime.planType },
|
||||
}),
|
||||
}
|
||||
})
|
||||
const render = (ui: ReactElement) => {
|
||||
const queryClient = createConsoleQueryClient()
|
||||
seedFeatures(queryClient, { billing: { subscription: { plan: scenario.planType } } })
|
||||
return renderWithConsoleQuery(ui, {
|
||||
queryClient,
|
||||
systemFeatures: { deployment_edition: scenario.deploymentEdition },
|
||||
})
|
||||
}
|
||||
|
||||
const mockTrackEvent = vi.fn()
|
||||
vi.mock('@/app/components/base/amplitude/utils', () => ({
|
||||
@ -70,11 +63,8 @@ describe('Filter', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockRuntime.deploymentEdition = 'CLOUD'
|
||||
mockRuntime.enableBilling = true
|
||||
mockRuntime.isFetchedPlan = true
|
||||
mockRuntime.isFetchedPlanInfo = true
|
||||
mockRuntime.planType = 'professional'
|
||||
scenario.deploymentEdition = 'CLOUD'
|
||||
scenario.planType = 'professional'
|
||||
})
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@ -214,8 +204,8 @@ describe('Filter', () => {
|
||||
describe('Time Period Filter', () => {
|
||||
it('should only show supported periods for Cloud sandbox workspaces', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockRuntime.deploymentEdition = 'CLOUD'
|
||||
mockRuntime.planType = 'sandbox'
|
||||
scenario.deploymentEdition = 'CLOUD'
|
||||
scenario.planType = 'sandbox'
|
||||
|
||||
render(
|
||||
<Filter queryParams={createDefaultQueryParams()} setQueryParams={defaultSetQueryParams} />,
|
||||
@ -237,8 +227,8 @@ describe('Filter', () => {
|
||||
|
||||
it('should keep all periods for sandbox workspaces outside Cloud', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockRuntime.deploymentEdition = 'COMMUNITY'
|
||||
mockRuntime.planType = 'sandbox'
|
||||
scenario.deploymentEdition = 'COMMUNITY'
|
||||
scenario.planType = 'sandbox'
|
||||
|
||||
render(
|
||||
<Filter queryParams={createDefaultQueryParams()} setQueryParams={defaultSetQueryParams} />,
|
||||
@ -253,8 +243,8 @@ describe('Filter', () => {
|
||||
it('should reset the Cloud sandbox period to today when cleared', async () => {
|
||||
const user = userEvent.setup()
|
||||
const setQueryParams = vi.fn()
|
||||
mockRuntime.deploymentEdition = 'CLOUD'
|
||||
mockRuntime.planType = 'sandbox'
|
||||
scenario.deploymentEdition = 'CLOUD'
|
||||
scenario.planType = 'sandbox'
|
||||
|
||||
render(
|
||||
<Filter
|
||||
|
||||
@ -54,7 +54,7 @@ const normalizeResetDate = (resetDate: number) => {
|
||||
return null
|
||||
}
|
||||
|
||||
const getResetInDaysFromDate = (resetDate: number) => {
|
||||
export const getResetInDaysFromDate = (resetDate: number) => {
|
||||
const resetDay = normalizeResetDate(resetDate)
|
||||
if (!resetDay) return null
|
||||
|
||||
|
||||
@ -657,7 +657,6 @@ describe('MainNav', () => {
|
||||
;(useProviderContext as Mock).mockReturnValue({
|
||||
enableBilling: true,
|
||||
enableEducationPlan: false,
|
||||
isFetchedPlan: true,
|
||||
plan: { type: 'sandbox' },
|
||||
} as ProviderContextState)
|
||||
;(useModalContext as Mock).mockReturnValue({
|
||||
@ -837,7 +836,6 @@ describe('MainNav', () => {
|
||||
;(useProviderContext as Mock).mockReturnValue({
|
||||
enableBilling: true,
|
||||
enableEducationPlan: true,
|
||||
isFetchedPlan: true,
|
||||
plan: { type: 'sandbox' },
|
||||
} as ProviderContextState)
|
||||
|
||||
|
||||
@ -179,7 +179,6 @@ describe('WorkspaceCard', () => {
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
enableBilling: true,
|
||||
enableEducationPlan: false,
|
||||
isFetchedPlan: true,
|
||||
plan: { type: 'sandbox' },
|
||||
} as ProviderContextState)
|
||||
mockWorkspacePermissionKeys(['workspace.member.manage'])
|
||||
@ -346,7 +345,6 @@ describe('WorkspaceCard', () => {
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
enableBilling: false,
|
||||
enableEducationPlan: false,
|
||||
isFetchedPlan: true,
|
||||
plan: { type: 'sandbox' },
|
||||
} as ProviderContextState)
|
||||
renderWorkspaceCard({ systemFeatures: { deployment_edition: 'CLOUD' } })
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
|
||||
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
|
||||
import type { ReactElement } from 'react'
|
||||
import type { AppPublisherProps } from '@/app/components/app/app-publisher/types'
|
||||
import type { App } from '@/types/app'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { createConsoleQueryWrapper, seedFeatures } from '@/test/console/query-data'
|
||||
import FeaturesTrigger from '../features-trigger'
|
||||
|
||||
const mockUseIsChatMode = vi.fn()
|
||||
@ -17,7 +18,6 @@ const mockUseChecklist = vi.fn()
|
||||
const mockUseChecklistBeforePublish = vi.fn()
|
||||
const mockUseNodesSyncDraft = vi.fn()
|
||||
const mockUseFeatures = vi.fn()
|
||||
const mockUseProviderContext = vi.fn()
|
||||
const mockUseNodes = vi.fn()
|
||||
const mockUseEdges = vi.fn()
|
||||
|
||||
@ -112,24 +112,10 @@ vi.mock('@/app/components/workflow/hooks-store', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useQueryClient: () => ({
|
||||
invalidateQueries: mockInvalidateQueries,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/base/features/hooks', () => ({
|
||||
useFeatures: (selector: (state: Record<string, unknown>) => unknown) => mockUseFeatures(selector),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockUseProviderContext(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
|
||||
default: () => mockUseNodes(),
|
||||
}))
|
||||
@ -253,23 +239,16 @@ vi.mock('@/hooks/use-theme', () => ({
|
||||
|
||||
// Use real app store - global zustand mock will auto-reset between tests
|
||||
|
||||
const createProviderContext = ({
|
||||
type = 'sandbox',
|
||||
isFetchedPlan = true,
|
||||
}: {
|
||||
type?: CloudPlan
|
||||
isFetchedPlan?: boolean
|
||||
}) => ({
|
||||
plan: { type },
|
||||
isFetchedPlan,
|
||||
})
|
||||
|
||||
const renderWithToast = (ui: ReactElement) => {
|
||||
const queryClient = new QueryClient()
|
||||
return {
|
||||
queryClient,
|
||||
...render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>),
|
||||
}
|
||||
const renderWithToast = (
|
||||
ui: ReactElement,
|
||||
{ edition = 'CLOUD', plan = 'sandbox' }: { edition?: DeploymentEdition; plan?: CloudPlan } = {},
|
||||
) => {
|
||||
const { queryClient, wrapper } = createConsoleQueryWrapper({
|
||||
systemFeatures: { deployment_edition: edition },
|
||||
})
|
||||
seedFeatures(queryClient, { billing: { subscription: { plan } } })
|
||||
vi.spyOn(queryClient, 'invalidateQueries').mockImplementation(mockInvalidateQueries)
|
||||
return { queryClient, ...render(ui, { wrapper }) }
|
||||
}
|
||||
|
||||
describe('FeaturesTrigger', () => {
|
||||
@ -295,7 +274,6 @@ describe('FeaturesTrigger', () => {
|
||||
mockUseFeatures.mockImplementation((selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({ features: { file: {} } }),
|
||||
)
|
||||
mockUseProviderContext.mockReturnValue(createProviderContext({}))
|
||||
mockUseNodes.mockReturnValue([])
|
||||
mockUseEdges.mockReturnValue([])
|
||||
// Set up app store state
|
||||
@ -467,24 +445,32 @@ describe('FeaturesTrigger', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should set startNodeLimitExceeded when sandbox entry limit is exceeded', () => {
|
||||
// Arrange
|
||||
mockUseNodes.mockReturnValue([
|
||||
{ id: 'start', data: { type: BlockEnum.Start } },
|
||||
{ id: 'trigger-1', data: { type: BlockEnum.TriggerWebhook } },
|
||||
{ id: 'trigger-2', data: { type: BlockEnum.TriggerSchedule } },
|
||||
{ id: 'end', data: { type: BlockEnum.End } },
|
||||
])
|
||||
it.each([
|
||||
{ edition: 'CLOUD', plan: 'sandbox', restricted: true },
|
||||
{ edition: 'CLOUD', plan: 'professional', restricted: false },
|
||||
{ edition: 'COMMUNITY', plan: 'sandbox', restricted: false },
|
||||
{ edition: 'ENTERPRISE', plan: 'sandbox', restricted: false },
|
||||
] as const)(
|
||||
'should apply the entry limit for $edition / $plan',
|
||||
({ edition, plan, restricted }) => {
|
||||
// Arrange
|
||||
mockUseNodes.mockReturnValue([
|
||||
{ id: 'start', data: { type: BlockEnum.Start } },
|
||||
{ id: 'trigger-1', data: { type: BlockEnum.TriggerWebhook } },
|
||||
{ id: 'trigger-2', data: { type: BlockEnum.TriggerSchedule } },
|
||||
{ id: 'end', data: { type: BlockEnum.End } },
|
||||
])
|
||||
|
||||
// Act
|
||||
renderWithToast(<FeaturesTrigger />)
|
||||
// Act
|
||||
renderWithToast(<FeaturesTrigger />, { edition, plan })
|
||||
|
||||
// Assert
|
||||
const publisher = screen.getByTestId('app-publisher')
|
||||
expect(publisher).toHaveAttribute('data-start-node-limit-exceeded', 'true')
|
||||
expect(publisher).toHaveAttribute('data-publish-disabled', 'true')
|
||||
expect(publisher).toHaveAttribute('data-has-trigger-node', 'true')
|
||||
})
|
||||
// Assert
|
||||
const publisher = screen.getByTestId('app-publisher')
|
||||
expect(publisher).toHaveAttribute('data-start-node-limit-exceeded', String(restricted))
|
||||
expect(publisher).toHaveAttribute('data-publish-disabled', String(restricted))
|
||||
expect(publisher).toHaveAttribute('data-has-trigger-node', 'true')
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
// Verifies callbacks wired from AppPublisher to stores and draft syncing.
|
||||
|
||||
@ -9,7 +9,7 @@ import type { CommonEdgeType, Node } from '@/app/components/workflow/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { memo, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEdges } from 'reactflow'
|
||||
@ -28,7 +28,7 @@ import { isAgentV2NodeData } from '@/app/components/workflow/nodes/agent-v2/type
|
||||
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
import { BlockEnum, InputVarType, isTriggerNode } from '@/app/components/workflow/types'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { fetchAppDetail } from '@/service/apps'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
@ -50,7 +50,16 @@ const FeaturesTrigger = () => {
|
||||
const appID = appDetail?.id
|
||||
const { nodesReadOnly, getNodesReadOnly } = useNodesReadOnly()
|
||||
const canReleaseAndVersion = useHooksStore((s) => s.accessControl.canReleaseAndVersion)
|
||||
const { plan, isFetchedPlan } = useProviderContext()
|
||||
const { data: deploymentEdition } = useSuspenseQuery({
|
||||
...systemFeaturesQueryOptions(),
|
||||
select: ({ deployment_edition }) => deployment_edition,
|
||||
})
|
||||
const { data: plan } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD',
|
||||
select: (features) => features.billing.subscription.plan,
|
||||
}),
|
||||
)
|
||||
const publishedAt = useStore((s) => s.publishedAt)
|
||||
const draftUpdatedAt = useStore((s) => s.draftUpdatedAt)
|
||||
const toolPublished = useStore((s) => s.toolPublished)
|
||||
@ -135,8 +144,8 @@ const FeaturesTrigger = () => {
|
||||
if (nodeType === BlockEnum.Start || isTriggerNode(nodeType)) return count + 1
|
||||
return count
|
||||
}, 0)
|
||||
return isFetchedPlan && plan.type === 'sandbox' && entryCount > 2
|
||||
}, [nodes, plan.type, isFetchedPlan])
|
||||
return deploymentEdition === 'CLOUD' && plan === 'sandbox' && entryCount > 2
|
||||
}, [nodes, plan, deploymentEdition])
|
||||
|
||||
const hasHumanInputNode = useMemo(() => {
|
||||
return nodes.some((node) => node.data.type === BlockEnum.HumanInput)
|
||||
|
||||
@ -187,7 +187,6 @@ const renderPanelElement = (data?: Partial<LLMNodeType>) => (
|
||||
plugin_id: 'langgenius/openai',
|
||||
} as unknown as ModelProviderSummaryResponse,
|
||||
],
|
||||
isFetchedPlan: true,
|
||||
})}
|
||||
>
|
||||
<Panel id="llm-node" data={{ ...baseNodeData, ...data }} panelProps={panelProps} />
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { NUM_INFINITE } from '@/app/components/billing/config'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getResetInDaysFromDate } from '@/app/components/billing/utils'
|
||||
import { currentWorkspaceIdAtom } from '@/context/workspace-state'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { isServer } from '@/utils/client'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
type TriggerEventsLimitModalContent = {
|
||||
usage: number
|
||||
@ -12,24 +13,6 @@ type TriggerEventsLimitModalContent = {
|
||||
resetInDays?: number
|
||||
}
|
||||
|
||||
type TriggerEventsLimitModalState = TriggerEventsLimitModalContent & {
|
||||
storageKey: string
|
||||
persistDismiss: boolean
|
||||
}
|
||||
|
||||
type TriggerPlanInfo = {
|
||||
type: CloudPlan
|
||||
usage: { triggerEvents: number }
|
||||
total: { triggerEvents: number }
|
||||
reset: { triggerEvents?: number | null }
|
||||
}
|
||||
|
||||
type UseTriggerEventsLimitModalOptions = {
|
||||
plan: TriggerPlanInfo
|
||||
isFetchedPlan: boolean
|
||||
currentWorkspaceId?: string
|
||||
}
|
||||
|
||||
type UseTriggerEventsLimitModalResult = {
|
||||
triggerEventsLimitModal: TriggerEventsLimitModalContent | null
|
||||
dismissTriggerEventsLimitModal: () => void
|
||||
@ -37,89 +20,72 @@ type UseTriggerEventsLimitModalResult = {
|
||||
|
||||
const TRIGGER_EVENTS_LOCALSTORAGE_PREFIX = 'trigger-events-limit-dismissed'
|
||||
|
||||
export const useTriggerEventsLimitModal = ({
|
||||
plan,
|
||||
isFetchedPlan,
|
||||
currentWorkspaceId,
|
||||
}: UseTriggerEventsLimitModalOptions): UseTriggerEventsLimitModalResult => {
|
||||
export const useTriggerEventsLimitModal = (): UseTriggerEventsLimitModalResult => {
|
||||
const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom)
|
||||
const { data: deploymentEdition } = useSuspenseQuery({
|
||||
...systemFeaturesQueryOptions(),
|
||||
select: ({ deployment_edition }) => deployment_edition,
|
||||
})
|
||||
const [triggerEventsLimitModal, setTriggerEventsLimitModal] =
|
||||
useState<TriggerEventsLimitModalState | null>(null)
|
||||
const dismissedTriggerEventsLimitStorageKeysRef = useRef<Record<string, boolean>>({})
|
||||
const { data: quota } = useQuery(
|
||||
consoleQuery.features.get.queryOptions({
|
||||
enabled: deploymentEdition === 'CLOUD',
|
||||
select: ({ billing, trigger_event }) => ({
|
||||
plan: billing.subscription.plan,
|
||||
...trigger_event,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const [dismissedCycles, setDismissedCycles] = useState<Record<string, boolean>>({})
|
||||
const resetInDays = quota ? getResetInDaysFromDate(quota.reset_date) : null
|
||||
const cycleTag =
|
||||
resetInDays !== null
|
||||
? dayjs().startOf('day').add(resetInDays, 'day').format('YYYY-MM-DD')
|
||||
: quota?.plan === 'sandbox'
|
||||
? dayjs().endOf('month').format('YYYY-MM-DD')
|
||||
: 'none'
|
||||
const storageKey =
|
||||
deploymentEdition === 'CLOUD' &&
|
||||
currentWorkspaceId &&
|
||||
quota &&
|
||||
quota.plan !== 'team' &&
|
||||
quota.limit > 0 &&
|
||||
quota.usage >= quota.limit
|
||||
? `${TRIGGER_EVENTS_LOCALSTORAGE_PREFIX}-${currentWorkspaceId}-${quota.plan}-${quota.limit}-${cycleTag}`
|
||||
: null
|
||||
const dismissed = storageKey ? dismissedCycles[storageKey] : undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (deploymentEdition !== 'CLOUD') return
|
||||
if (isServer) return
|
||||
if (!currentWorkspaceId) return
|
||||
if (!isFetchedPlan) {
|
||||
setTriggerEventsLimitModal(null)
|
||||
return
|
||||
}
|
||||
if (!storageKey || dismissed !== undefined) return
|
||||
|
||||
const { type, usage, total, reset } = plan
|
||||
const isUnlimited = total.triggerEvents === NUM_INFINITE
|
||||
const reachedLimit = total.triggerEvents > 0 && usage.triggerEvents >= total.triggerEvents
|
||||
|
||||
if (type === 'team' || isUnlimited || !reachedLimit) {
|
||||
if (triggerEventsLimitModal) setTriggerEventsLimitModal(null)
|
||||
return
|
||||
}
|
||||
|
||||
const triggerResetInDays =
|
||||
type === 'professional' && total.triggerEvents !== NUM_INFINITE
|
||||
? (reset.triggerEvents ?? undefined)
|
||||
: undefined
|
||||
const cycleTag = (() => {
|
||||
if (typeof reset.triggerEvents === 'number')
|
||||
return dayjs().startOf('day').add(reset.triggerEvents, 'day').format('YYYY-MM-DD')
|
||||
if (type === 'sandbox') return dayjs().endOf('month').format('YYYY-MM-DD')
|
||||
return 'none'
|
||||
})()
|
||||
const storageKey = `${TRIGGER_EVENTS_LOCALSTORAGE_PREFIX}-${currentWorkspaceId}-${type}-${total.triggerEvents}-${cycleTag}`
|
||||
if (dismissedTriggerEventsLimitStorageKeysRef.current[storageKey]) return
|
||||
|
||||
let persistDismiss = true
|
||||
let hasDismissed = false
|
||||
let storedDismissal = false
|
||||
try {
|
||||
if (localStorage.getItem(storageKey) === '1') hasDismissed = true
|
||||
storedDismissal = localStorage.getItem(storageKey) === '1'
|
||||
} catch {
|
||||
persistDismiss = false
|
||||
// Storage can be unavailable; dismissal still lasts for this mounted session.
|
||||
}
|
||||
if (hasDismissed) return
|
||||
setDismissedCycles((current) => ({ ...current, [storageKey]: storedDismissal }))
|
||||
}, [storageKey, dismissed])
|
||||
|
||||
if (triggerEventsLimitModal?.storageKey === storageKey) return
|
||||
const dismissTriggerEventsLimitModal = () => {
|
||||
if (!storageKey) return
|
||||
|
||||
setTriggerEventsLimitModal({
|
||||
usage: usage.triggerEvents,
|
||||
total: total.triggerEvents,
|
||||
resetInDays: triggerResetInDays,
|
||||
storageKey,
|
||||
persistDismiss,
|
||||
})
|
||||
}, [plan, isFetchedPlan, triggerEventsLimitModal, currentWorkspaceId, deploymentEdition])
|
||||
|
||||
const dismissTriggerEventsLimitModal = useCallback(() => {
|
||||
if (!triggerEventsLimitModal) return
|
||||
|
||||
const { storageKey, persistDismiss } = triggerEventsLimitModal
|
||||
if (persistDismiss) {
|
||||
try {
|
||||
localStorage.setItem(storageKey, '1')
|
||||
setTriggerEventsLimitModal(null)
|
||||
return
|
||||
} catch {
|
||||
// ignore error and fall back to in-memory guard
|
||||
}
|
||||
setDismissedCycles((current) => ({ ...current, [storageKey]: true }))
|
||||
try {
|
||||
localStorage.setItem(storageKey, '1')
|
||||
} catch {
|
||||
// The in-memory dismissal above also covers failed storage writes.
|
||||
}
|
||||
dismissedTriggerEventsLimitStorageKeysRef.current[storageKey] = true
|
||||
setTriggerEventsLimitModal(null)
|
||||
}, [triggerEventsLimitModal])
|
||||
}
|
||||
|
||||
return {
|
||||
triggerEventsLimitModal,
|
||||
triggerEventsLimitModal:
|
||||
storageKey && dismissed === false && quota
|
||||
? {
|
||||
usage: quota.usage,
|
||||
total: quota.limit,
|
||||
resetInDays: quota.plan === 'professional' ? (resetInDays ?? undefined) : undefined,
|
||||
}
|
||||
: null,
|
||||
dismissTriggerEventsLimitModal,
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,11 +8,8 @@ import type { UpdatePluginPayload } from '@/app/components/plugins/types'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import type { ExternalDataTool } from '@/models/common'
|
||||
import type { ModerationConfig, PromptVariable } from '@/models/debug'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { currentWorkspaceIdAtom } from '@/context/workspace-state'
|
||||
import { usePricingModal } from '@/hooks/use-query-params'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { useTriggerEventsLimitModal } from './hooks/use-trigger-events-limit-modal'
|
||||
@ -93,15 +90,8 @@ export const ModalContextProvider = ({ children }: ModalContextProviderProps) =>
|
||||
> | null>(null)
|
||||
const [showUpdatePluginModal, setShowUpdatePluginModal] =
|
||||
useState<ModalState<UpdatePluginPayload> | null>(null)
|
||||
const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom)
|
||||
|
||||
const [showAnnotationFullModal, setShowAnnotationFullModal] = useState(false)
|
||||
const { plan, isFetchedPlan } = useProviderContext()
|
||||
const { triggerEventsLimitModal, dismissTriggerEventsLimitModal } = useTriggerEventsLimitModal({
|
||||
plan,
|
||||
isFetchedPlan,
|
||||
currentWorkspaceId,
|
||||
})
|
||||
const { triggerEventsLimitModal, dismissTriggerEventsLimitModal } = useTriggerEventsLimitModal()
|
||||
|
||||
const handleCancelModerationSettingModal = () => {
|
||||
setShowModerationSettingModal(null)
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
|
||||
import { act, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import dayjs from 'dayjs'
|
||||
import * as React from 'react'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types'
|
||||
import { useModalContextSelector } from '@/context/modal-context'
|
||||
import { ModalContextProvider } from '@/context/modal-context-provider'
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { createConsoleQueryWrapper, seedFeatures } from '@/test/console/query-data'
|
||||
import { render } from '@/test/console/render'
|
||||
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
|
||||
|
||||
@ -29,11 +29,6 @@ vi.mock('@/app/components/plugins/update-plugin', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
const mockUseProviderContext = vi.fn()
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockUseProviderContext(),
|
||||
}))
|
||||
|
||||
const mockConsoleStateReader = vi.fn()
|
||||
|
||||
vi.mock('@/context/workspace-state', async () => {
|
||||
@ -41,39 +36,6 @@ vi.mock('@/context/workspace-state', async () => {
|
||||
return createWorkspaceStateModuleMock(() => mockConsoleStateReader())
|
||||
})
|
||||
|
||||
type DefaultPlanShape = typeof defaultPlan
|
||||
type ResetShape = {
|
||||
apiRateLimit: number | null
|
||||
triggerEvents: number | null
|
||||
}
|
||||
type PlanShape = Omit<DefaultPlanShape, 'type' | 'reset'> & {
|
||||
type: CloudPlan
|
||||
reset: ResetShape
|
||||
}
|
||||
type PlanOverrides = Partial<Omit<DefaultPlanShape, 'type' | 'usage' | 'total' | 'reset'>> & {
|
||||
type?: CloudPlan
|
||||
usage?: Partial<DefaultPlanShape['usage']>
|
||||
total?: Partial<DefaultPlanShape['total']>
|
||||
reset?: Partial<ResetShape>
|
||||
}
|
||||
|
||||
const createPlan = (overrides: PlanOverrides = {}): PlanShape => ({
|
||||
...defaultPlan,
|
||||
...overrides,
|
||||
usage: {
|
||||
...defaultPlan.usage,
|
||||
...overrides.usage,
|
||||
},
|
||||
total: {
|
||||
...defaultPlan.total,
|
||||
...overrides.total,
|
||||
},
|
||||
reset: {
|
||||
...defaultPlan.reset,
|
||||
...overrides.reset,
|
||||
},
|
||||
})
|
||||
|
||||
const ModalBlockingState = () => {
|
||||
const hasBlockingModalOpen = useModalContextSelector((state) => state.hasBlockingModalOpen)
|
||||
|
||||
@ -117,10 +79,15 @@ const UpdatePluginTrigger = ({
|
||||
)
|
||||
}
|
||||
|
||||
const renderProvider = (children: React.ReactNode = <ModalBlockingState />) => {
|
||||
const { wrapper: QueryWrapper } = createConsoleQueryWrapper({
|
||||
systemFeatures: { deployment_edition: 'CLOUD' },
|
||||
const renderProvider = (
|
||||
children: React.ReactNode = <ModalBlockingState />,
|
||||
features: Parameters<typeof seedFeatures>[1] = {},
|
||||
edition: DeploymentEdition = 'CLOUD',
|
||||
) => {
|
||||
const { wrapper: QueryWrapper, queryClient } = createConsoleQueryWrapper({
|
||||
systemFeatures: { deployment_edition: edition },
|
||||
})
|
||||
seedFeatures(queryClient, features)
|
||||
const { wrapper: NuqsWrapper } = createNuqsTestWrapper()
|
||||
const wrapper = ({ children: wrapperChildren }: { children: React.ReactNode }) => (
|
||||
<QueryWrapper>
|
||||
@ -128,13 +95,15 @@ const renderProvider = (children: React.ReactNode = <ModalBlockingState />) => {
|
||||
</QueryWrapper>
|
||||
)
|
||||
|
||||
return render(<ModalContextProvider>{children}</ModalContextProvider>, { wrapper })
|
||||
return {
|
||||
queryClient,
|
||||
...render(<ModalContextProvider>{children}</ModalContextProvider>, { wrapper }),
|
||||
}
|
||||
}
|
||||
|
||||
describe('ModalContextProvider trigger events limit modal', () => {
|
||||
beforeEach(() => {
|
||||
mockConsoleStateReader.mockReset()
|
||||
mockUseProviderContext.mockReset()
|
||||
window.localStorage.clear()
|
||||
mockConsoleStateReader.mockReturnValue({
|
||||
currentWorkspace: {
|
||||
@ -147,23 +116,60 @@ describe('ModalContextProvider trigger events limit modal', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('updates the visible quota and closes the modal when usage drops below the limit', async () => {
|
||||
const features = {
|
||||
billing: { subscription: { plan: 'professional' as const } },
|
||||
trigger_event: { usage: 200, limit: 200, reset_date: dayjs().add(3, 'day').unix() },
|
||||
}
|
||||
const { queryClient } = renderProvider(undefined, features)
|
||||
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument()
|
||||
act(() => {
|
||||
seedFeatures(queryClient, {
|
||||
...features,
|
||||
trigger_event: { ...features.trigger_event, usage: 250 },
|
||||
})
|
||||
})
|
||||
expect(await screen.findByText('250')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
seedFeatures(queryClient, {
|
||||
...features,
|
||||
trigger_event: { ...features.trigger_event, usage: 100 },
|
||||
})
|
||||
})
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('clear')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each(['COMMUNITY', 'ENTERPRISE'] as const)(
|
||||
'does not show Cloud quota prompts in %s',
|
||||
(edition) => {
|
||||
renderProvider(
|
||||
undefined,
|
||||
{
|
||||
billing: { subscription: { plan: 'sandbox' } },
|
||||
trigger_event: { usage: 200, limit: 200 },
|
||||
},
|
||||
edition,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('clear')).toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
|
||||
it('opens the trigger events limit modal and persists dismissal in localStorage', async () => {
|
||||
const plan = createPlan({
|
||||
type: 'professional',
|
||||
usage: { triggerEvents: 3000 },
|
||||
total: { triggerEvents: 3000 },
|
||||
reset: { triggerEvents: 5 },
|
||||
})
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
plan,
|
||||
isFetchedPlan: true,
|
||||
})
|
||||
const features = {
|
||||
billing: { subscription: { plan: 'professional' as const } },
|
||||
trigger_event: { usage: 3000, limit: 3000, reset_date: dayjs().add(5, 'day').unix() },
|
||||
}
|
||||
// Note: vitest.setup.ts replaces localStorage with a mock object that has vi.fn() methods
|
||||
// We need to spy on the mock's setItem, not Storage.prototype.setItem
|
||||
const setItemSpy = vi.spyOn(localStorage, 'setItem')
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderProvider()
|
||||
renderProvider(undefined, features)
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
expect(screen.getAllByText('3000')).toHaveLength(2)
|
||||
@ -182,23 +188,16 @@ describe('ModalContextProvider trigger events limit modal', () => {
|
||||
})
|
||||
|
||||
it('relies on the in-memory guard when localStorage reads throw', async () => {
|
||||
const plan = createPlan({
|
||||
type: 'professional',
|
||||
usage: { triggerEvents: 200 },
|
||||
total: { triggerEvents: 200 },
|
||||
reset: { triggerEvents: 3 },
|
||||
})
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
plan,
|
||||
isFetchedPlan: true,
|
||||
})
|
||||
const features = {
|
||||
billing: { subscription: { plan: 'professional' as const } },
|
||||
trigger_event: { usage: 200, limit: 200, reset_date: dayjs().add(3, 'day').unix() },
|
||||
}
|
||||
vi.spyOn(localStorage, 'getItem').mockImplementation(() => {
|
||||
throw new Error('Storage disabled')
|
||||
})
|
||||
const setItemSpy = vi.spyOn(localStorage, 'setItem')
|
||||
const user = userEvent.setup()
|
||||
|
||||
const { rerender } = renderProvider()
|
||||
const { rerender } = renderProvider(undefined, features)
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
|
||||
@ -212,26 +211,19 @@ describe('ModalContextProvider trigger events limit modal', () => {
|
||||
)
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('clear')).toBeInTheDocument()
|
||||
expect(setItemSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to the in-memory guard when localStorage.setItem fails', async () => {
|
||||
const plan = createPlan({
|
||||
type: 'professional',
|
||||
usage: { triggerEvents: 120 },
|
||||
total: { triggerEvents: 120 },
|
||||
reset: { triggerEvents: 2 },
|
||||
})
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
plan,
|
||||
isFetchedPlan: true,
|
||||
})
|
||||
const features = {
|
||||
billing: { subscription: { plan: 'professional' as const } },
|
||||
trigger_event: { usage: 120, limit: 120, reset_date: dayjs().add(2, 'day').unix() },
|
||||
}
|
||||
vi.spyOn(localStorage, 'setItem').mockImplementation(() => {
|
||||
throw new Error('Quota exceeded')
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
|
||||
const { rerender } = renderProvider()
|
||||
const { rerender } = renderProvider(undefined, features)
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
|
||||
@ -248,19 +240,13 @@ describe('ModalContextProvider trigger events limit modal', () => {
|
||||
})
|
||||
|
||||
it('closes the trigger events limit modal and opens pricing when upgrading', async () => {
|
||||
const plan = createPlan({
|
||||
type: 'professional',
|
||||
usage: { triggerEvents: 400 },
|
||||
total: { triggerEvents: 400 },
|
||||
reset: { triggerEvents: 6 },
|
||||
})
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
plan,
|
||||
isFetchedPlan: true,
|
||||
})
|
||||
const features = {
|
||||
billing: { subscription: { plan: 'professional' as const } },
|
||||
trigger_event: { usage: 400, limit: 400, reset_date: dayjs().add(6, 'day').unix() },
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderProvider()
|
||||
renderProvider(undefined, features)
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
|
||||
|
||||
@ -277,16 +263,11 @@ describe('ModalContextProvider trigger events limit modal', () => {
|
||||
describe('ModalContextProvider plugin update modal', () => {
|
||||
beforeEach(() => {
|
||||
mockConsoleStateReader.mockReset()
|
||||
mockUseProviderContext.mockReset()
|
||||
mockConsoleStateReader.mockReturnValue({
|
||||
currentWorkspace: {
|
||||
id: 'workspace-1',
|
||||
},
|
||||
})
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
plan: createPlan(),
|
||||
isFetchedPlan: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a model plugin update open until its refresh callback finishes', async () => {
|
||||
|
||||
@ -40,8 +40,6 @@ export const ProviderContextProvider = ({ children }: ProviderContextProviderPro
|
||||
const features = featuresQuery.data
|
||||
const enableBilling = features?.billing.enabled ?? false
|
||||
const plan = enableBilling && features ? parseCurrentPlan(features) : defaultPlan
|
||||
const isFetchedPlan = featuresQuery.isSuccess && enableBilling
|
||||
const isFetchedPlanInfo = featuresQuery.isFetched
|
||||
const enableEducationPlan = features?.education.enabled ?? false
|
||||
const enableSkill = features?.enable_skill ?? false
|
||||
const enableReplaceWebAppLogo = features?.can_replace_logo ?? false
|
||||
@ -90,8 +88,6 @@ export const ProviderContextProvider = ({ children }: ProviderContextProviderPro
|
||||
),
|
||||
supportRetrievalMethods: supportRetrievalMethods?.retrieval_method || [],
|
||||
plan,
|
||||
isFetchedPlan,
|
||||
isFetchedPlanInfo,
|
||||
enableBilling,
|
||||
enableSkill,
|
||||
enableReplaceWebAppLogo,
|
||||
|
||||
@ -26,8 +26,6 @@ export type ProviderContextState = {
|
||||
total: UsagePlanInfo
|
||||
reset: UsageResetInfo
|
||||
}
|
||||
isFetchedPlan: boolean
|
||||
isFetchedPlanInfo: boolean
|
||||
enableBilling: boolean
|
||||
enableSkill: boolean
|
||||
enableReplaceWebAppLogo: boolean
|
||||
@ -49,8 +47,6 @@ export const baseProviderContextValue: ProviderContextState = {
|
||||
supportRetrievalMethods: [],
|
||||
isAPIKeySet: true,
|
||||
plan: defaultPlan,
|
||||
isFetchedPlan: false,
|
||||
isFetchedPlanInfo: false,
|
||||
enableBilling: false,
|
||||
enableSkill: false,
|
||||
enableReplaceWebAppLogo: false,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user