refactor(web): remove provider plan query flags (#41908)

This commit is contained in:
yyh 2026-09-07 06:37:26 +00:00 committed by GitHub
parent 3be0cabf0a
commit 5c0d3c4393
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 311 additions and 434 deletions

View File

@ -4796,7 +4796,7 @@
}, },
"web/context/hooks/use-trigger-events-limit-modal.ts": { "web/context/hooks/use-trigger-events-limit-modal.ts": {
"eslint-react/set-state-in-effect": { "eslint-react/set-state-in-effect": {
"count": 3 "count": 1
}, },
"no-restricted-globals": { "no-restricted-globals": {
"count": 2 "count": 2

View File

@ -16,8 +16,6 @@ export const baseProviderContextValue: ProviderContextState = {
supportRetrievalMethods: [], supportRetrievalMethods: [],
isAPIKeySet: true, isAPIKeySet: true,
plan: defaultPlan, plan: defaultPlan,
isFetchedPlan: false,
isFetchedPlanInfo: false,
enableBilling: false, enableBilling: false,
enableSkill: false, enableSkill: false,
enableReplaceWebAppLogo: false, enableReplaceWebAppLogo: false,

View File

@ -115,7 +115,6 @@ const setupProviderContext = (
mockProviderCtx = { mockProviderCtx = {
plan: createPlanData(planOverrides), plan: createPlanData(planOverrides),
enableBilling: true, enableBilling: true,
isFetchedPlan: true,
enableEducationPlan: false, enableEducationPlan: false,
...extra, ...extra,
} }

View File

@ -94,7 +94,6 @@ const setupContexts = (
mockProviderCtx = { mockProviderCtx = {
plan: createPlanData(planOverrides), plan: createPlanData(planOverrides),
enableBilling: true, enableBilling: true,
isFetchedPlan: true,
enableEducationPlan: false, enableEducationPlan: false,
...providerOverrides, ...providerOverrides,
} }

View File

@ -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 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' import Filter, { TIME_PERIOD_MAPPING } from '../filter'
let mockAnnotationsCountLoading = false let mockAnnotationsCountLoading = false
let mockAnnotationsCountData: { count: number } | null = { count: 10 } let mockAnnotationsCountData: { count: number } | null = { count: 10 }
const mockRuntime = vi.hoisted(() => ({ const scenario = {
deploymentEdition: 'CLOUD', deploymentEdition: 'CLOUD' as DeploymentEdition,
enableBilling: true, pending: false,
isFetchedPlan: true, planType: 'professional' as CloudPlan,
isFetchedPlanInfo: true, }
planType: 'professional',
}))
vi.mock('@tanstack/react-query', async (importOriginal) => { const render = (ui: ReactElement) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>() const queryClient = createConsoleQueryClient()
return { if (scenario.pending) {
...actual, void queryClient.query({
useSuspenseQuery: () => ({ data: mockRuntime.deploymentEdition }), queryKey: consoleQuery.features.get.queryKey(),
} queryFn: () => new Promise(() => {}),
}) })
} else seedFeatures(queryClient, { billing: { subscription: { plan: scenario.planType } } })
vi.mock('@/context/provider-context', async (importOriginal) => { return renderWithConsoleQuery(ui, {
const actual = await importOriginal<typeof import('@/context/provider-context')>() queryClient,
return { systemFeatures: { deployment_edition: scenario.deploymentEdition },
...actual, })
useProviderContext: () => ({ }
enableBilling: mockRuntime.enableBilling,
isFetchedPlan: mockRuntime.isFetchedPlan,
isFetchedPlanInfo: mockRuntime.isFetchedPlanInfo,
plan: { type: mockRuntime.planType },
}),
}
})
vi.mock('@/service/use-log', () => ({ vi.mock('@/service/use-log', () => ({
useAnnotationsCount: () => ({ useAnnotationsCount: () => ({
@ -102,11 +102,9 @@ describe('Filter', () => {
vi.clearAllMocks() vi.clearAllMocks()
mockAnnotationsCountLoading = false mockAnnotationsCountLoading = false
mockAnnotationsCountData = { count: 10 } mockAnnotationsCountData = { count: 10 }
mockRuntime.deploymentEdition = 'CLOUD' scenario.deploymentEdition = 'CLOUD'
mockRuntime.enableBilling = true scenario.pending = false
mockRuntime.isFetchedPlan = true scenario.planType = 'professional'
mockRuntime.isFetchedPlanInfo = true
mockRuntime.planType = 'professional'
}) })
describe('Rendering', () => { describe('Rendering', () => {
@ -179,8 +177,8 @@ describe('Filter', () => {
describe('User Interactions', () => { describe('User Interactions', () => {
it('should only show supported periods for Cloud sandbox workspaces', () => { it('should only show supported periods for Cloud sandbox workspaces', () => {
mockRuntime.deploymentEdition = 'CLOUD' scenario.deploymentEdition = 'CLOUD'
mockRuntime.planType = 'sandbox' scenario.planType = 'sandbox'
render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />) 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', () => { it('should keep periods restricted until the Cloud plan resolves, then follow cache updates', async () => {
mockRuntime.isFetchedPlan = false scenario.pending = true
mockRuntime.isFetchedPlanInfo = false
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' })) fireEvent.click(screen.getByRole('button', { name: 'open-options-1' }))
@ -208,36 +207,36 @@ describe('Filter', () => {
expect.stringMatching(/(?:^|\.)filter\.period\.last7days(?=$|:)/), expect.stringMatching(/(?:^|\.)filter\.period\.last7days(?=$|:)/),
expect.stringMatching(/(?:^|\.)filter\.period\.last30days(?=$|:)/), 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', () => { it.each(['COMMUNITY', 'ENTERPRISE'] as const)(
mockRuntime.enableBilling = false 'should keep all periods for sandbox workspaces in %s',
mockRuntime.isFetchedPlan = false (edition) => {
mockRuntime.isFetchedPlanInfo = true 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' })) const periodOptions = within(screen.getByRole('list', { name: 'options-1' }))
expect(periodOptions.getAllByRole('listitem')).toHaveLength(9) 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)
})
it('should reset the Cloud sandbox period to today when cleared', () => { it('should reset the Cloud sandbox period to today when cleared', () => {
mockRuntime.deploymentEdition = 'CLOUD' scenario.deploymentEdition = 'CLOUD'
mockRuntime.planType = 'sandbox' scenario.planType = 'sandbox'
render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />) render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)

View File

@ -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 type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
import { screen, within } from '@testing-library/react' import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event' 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 { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context' import { consoleQuery } from '@/service/client'
import { createConsoleQueryWrapper } from '@/test/console/query-data' import { createConsoleQueryWrapper, seedFeatures } from '@/test/console/query-data'
import { render } from '@/test/console/render' import { render } from '@/test/console/render'
import { RetentionUpgradeNotice } from '../retention-upgrade-notice' 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) => { vi.mock('@/context/modal-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/modal-context')>() const actual = await importOriginal<typeof import('@/context/modal-context')>()
return { return {
@ -26,46 +16,30 @@ vi.mock('@/context/modal-context', async (importOriginal) => {
} }
}) })
const mockUseProviderContext = vi.mocked(useProviderContext)
const mockUseModalContext = vi.mocked(useModalContext) const mockUseModalContext = vi.mocked(useModalContext)
describe('RetentionUpgradeNotice', () => { describe('RetentionUpgradeNotice', () => {
const setShowPricingModal = vi.fn() const setShowPricingModal = vi.fn()
function mockProvider({ function renderNotice(
enableBilling = true, deploymentEdition: DeploymentEdition = 'CLOUD',
isFetchedPlan = true, plan: CloudPlan | null = 'sandbox',
isFetchedPlanInfo = true, ) {
planType = 'sandbox', const { wrapper, queryClient } = createConsoleQueryWrapper({
}: {
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({
systemFeatures: { deployment_edition: deploymentEdition }, 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 }) return render(<RetentionUpgradeNotice />, { wrapper })
} }
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockProvider()
mockUseModalContext.mockReturnValue({ mockUseModalContext.mockReturnValue({
setShowPricingModal, setShowPricingModal,
} as unknown as ReturnType<typeof useModalContext>) } as unknown as ReturnType<typeof useModalContext>)
@ -89,28 +63,26 @@ describe('RetentionUpgradeNotice', () => {
it.each([ it.each([
{ {
name: 'paid Cloud workspaces', name: 'paid Cloud workspaces',
provider: { planType: 'professional' }, plan: 'professional',
deploymentEdition: 'CLOUD', deploymentEdition: 'CLOUD',
}, },
{ {
name: 'self-hosted sandbox workspaces', name: 'self-hosted sandbox workspaces',
provider: { planType: 'sandbox' }, plan: 'sandbox',
deploymentEdition: 'COMMUNITY', deploymentEdition: 'COMMUNITY',
}, },
{ {
name: 'workspaces without billing', name: 'Enterprise workspaces',
provider: { enableBilling: false }, plan: 'sandbox',
deploymentEdition: 'CLOUD', deploymentEdition: 'ENTERPRISE',
}, },
{ {
name: 'workspaces before plan loading completes', name: 'workspaces before plan loading completes',
provider: { isFetchedPlan: false, isFetchedPlanInfo: false }, plan: null,
deploymentEdition: 'CLOUD', deploymentEdition: 'CLOUD',
}, },
] as const)('should not show guidance for $name', ({ provider, deploymentEdition }) => { ] as const)('should not show guidance for $name', ({ plan, deploymentEdition }) => {
mockProvider(provider) renderNotice(deploymentEdition, plan)
renderNotice(deploymentEdition)
expect(screen.queryByRole('status')).not.toBeInTheDocument() expect(screen.queryByRole('status')).not.toBeInTheDocument()
}) })

View File

@ -1,8 +1,8 @@
'use client' 'use client'
import { useSuspenseQuery } from '@tanstack/react-query' import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client' 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_TIME_PERIOD_KEYS = new Set(['1', '2', '3'])
export const CLOUD_SANDBOX_CLEARED_TIME_PERIOD = '1' export const CLOUD_SANDBOX_CLEARED_TIME_PERIOD = '1'
@ -42,12 +42,15 @@ export function useCloudSandboxPlanStatus(): CloudSandboxPlanState {
...systemFeaturesQueryOptions(), ...systemFeaturesQueryOptions(),
select: ({ deployment_edition }) => deployment_edition, 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 (deploymentEdition !== 'CLOUD') return 'unrestricted'
if (!isFetchedPlanInfo) return 'pending' if (!plan) return 'pending'
if (!enableBilling) return 'unrestricted'
if (!isFetchedPlan) return 'pending'
return plan.type === 'sandbox' ? 'sandbox' : 'unrestricted' return plan === 'sandbox' ? 'sandbox' : 'unrestricted'
} }

View File

@ -46,8 +46,6 @@ const defaultProviderContext = {
supportRetrievalMethods: [], supportRetrievalMethods: [],
isAPIKeySet: false, isAPIKeySet: false,
plan: defaultPlan, plan: defaultPlan,
isFetchedPlan: false,
isFetchedPlanInfo: false,
enableBilling: false, enableBilling: false,
enableSkill: false, enableSkill: false,
enableReplaceWebAppLogo: false, enableReplaceWebAppLogo: false,

View File

@ -7,44 +7,37 @@
* - Keyword search * - 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 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 userEvent from '@testing-library/user-event'
import { useState } from 'react' import { useState } from 'react'
import {
createConsoleQueryClient,
renderWithConsoleQuery,
seedFeatures,
} from '@/test/console/query-data'
import Filter, { TIME_PERIOD_MAPPING } from '../filter' import Filter, { TIME_PERIOD_MAPPING } from '../filter'
// ============================================================================ // ============================================================================
// Mocks // Mocks
// ============================================================================ // ============================================================================
const mockRuntime = vi.hoisted(() => ({ const scenario = {
deploymentEdition: 'CLOUD', deploymentEdition: 'CLOUD' as DeploymentEdition,
enableBilling: true, planType: 'professional' as CloudPlan,
isFetchedPlan: true, }
isFetchedPlanInfo: true,
planType: 'professional',
}))
vi.mock('@tanstack/react-query', async (importOriginal) => { const render = (ui: ReactElement) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>() const queryClient = createConsoleQueryClient()
return { seedFeatures(queryClient, { billing: { subscription: { plan: scenario.planType } } })
...actual, return renderWithConsoleQuery(ui, {
useSuspenseQuery: () => ({ data: mockRuntime.deploymentEdition }), queryClient,
} systemFeatures: { deployment_edition: scenario.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 mockTrackEvent = vi.fn() const mockTrackEvent = vi.fn()
vi.mock('@/app/components/base/amplitude/utils', () => ({ vi.mock('@/app/components/base/amplitude/utils', () => ({
@ -70,11 +63,8 @@ describe('Filter', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mockRuntime.deploymentEdition = 'CLOUD' scenario.deploymentEdition = 'CLOUD'
mockRuntime.enableBilling = true scenario.planType = 'professional'
mockRuntime.isFetchedPlan = true
mockRuntime.isFetchedPlanInfo = true
mockRuntime.planType = 'professional'
}) })
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
@ -214,8 +204,8 @@ describe('Filter', () => {
describe('Time Period Filter', () => { describe('Time Period Filter', () => {
it('should only show supported periods for Cloud sandbox workspaces', async () => { it('should only show supported periods for Cloud sandbox workspaces', async () => {
const user = userEvent.setup() const user = userEvent.setup()
mockRuntime.deploymentEdition = 'CLOUD' scenario.deploymentEdition = 'CLOUD'
mockRuntime.planType = 'sandbox' scenario.planType = 'sandbox'
render( render(
<Filter queryParams={createDefaultQueryParams()} setQueryParams={defaultSetQueryParams} />, <Filter queryParams={createDefaultQueryParams()} setQueryParams={defaultSetQueryParams} />,
@ -237,8 +227,8 @@ describe('Filter', () => {
it('should keep all periods for sandbox workspaces outside Cloud', async () => { it('should keep all periods for sandbox workspaces outside Cloud', async () => {
const user = userEvent.setup() const user = userEvent.setup()
mockRuntime.deploymentEdition = 'COMMUNITY' scenario.deploymentEdition = 'COMMUNITY'
mockRuntime.planType = 'sandbox' scenario.planType = 'sandbox'
render( render(
<Filter queryParams={createDefaultQueryParams()} setQueryParams={defaultSetQueryParams} />, <Filter queryParams={createDefaultQueryParams()} setQueryParams={defaultSetQueryParams} />,
@ -253,8 +243,8 @@ describe('Filter', () => {
it('should reset the Cloud sandbox period to today when cleared', async () => { it('should reset the Cloud sandbox period to today when cleared', async () => {
const user = userEvent.setup() const user = userEvent.setup()
const setQueryParams = vi.fn() const setQueryParams = vi.fn()
mockRuntime.deploymentEdition = 'CLOUD' scenario.deploymentEdition = 'CLOUD'
mockRuntime.planType = 'sandbox' scenario.planType = 'sandbox'
render( render(
<Filter <Filter

View File

@ -54,7 +54,7 @@ const normalizeResetDate = (resetDate: number) => {
return null return null
} }
const getResetInDaysFromDate = (resetDate: number) => { export const getResetInDaysFromDate = (resetDate: number) => {
const resetDay = normalizeResetDate(resetDate) const resetDay = normalizeResetDate(resetDate)
if (!resetDay) return null if (!resetDay) return null

View File

@ -657,7 +657,6 @@ describe('MainNav', () => {
;(useProviderContext as Mock).mockReturnValue({ ;(useProviderContext as Mock).mockReturnValue({
enableBilling: true, enableBilling: true,
enableEducationPlan: false, enableEducationPlan: false,
isFetchedPlan: true,
plan: { type: 'sandbox' }, plan: { type: 'sandbox' },
} as ProviderContextState) } as ProviderContextState)
;(useModalContext as Mock).mockReturnValue({ ;(useModalContext as Mock).mockReturnValue({
@ -837,7 +836,6 @@ describe('MainNav', () => {
;(useProviderContext as Mock).mockReturnValue({ ;(useProviderContext as Mock).mockReturnValue({
enableBilling: true, enableBilling: true,
enableEducationPlan: true, enableEducationPlan: true,
isFetchedPlan: true,
plan: { type: 'sandbox' }, plan: { type: 'sandbox' },
} as ProviderContextState) } as ProviderContextState)

View File

@ -179,7 +179,6 @@ describe('WorkspaceCard', () => {
vi.mocked(useProviderContext).mockReturnValue({ vi.mocked(useProviderContext).mockReturnValue({
enableBilling: true, enableBilling: true,
enableEducationPlan: false, enableEducationPlan: false,
isFetchedPlan: true,
plan: { type: 'sandbox' }, plan: { type: 'sandbox' },
} as ProviderContextState) } as ProviderContextState)
mockWorkspacePermissionKeys(['workspace.member.manage']) mockWorkspacePermissionKeys(['workspace.member.manage'])
@ -346,7 +345,6 @@ describe('WorkspaceCard', () => {
vi.mocked(useProviderContext).mockReturnValue({ vi.mocked(useProviderContext).mockReturnValue({
enableBilling: false, enableBilling: false,
enableEducationPlan: false, enableEducationPlan: false,
isFetchedPlan: true,
plan: { type: 'sandbox' }, plan: { type: 'sandbox' },
} as ProviderContextState) } as ProviderContextState)
renderWorkspaceCard({ systemFeatures: { deployment_edition: 'CLOUD' } }) renderWorkspaceCard({ systemFeatures: { deployment_edition: 'CLOUD' } })

View File

@ -1,13 +1,14 @@
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen' 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 { ReactElement } from 'react'
import type { AppPublisherProps } from '@/app/components/app/app-publisher/types' import type { AppPublisherProps } from '@/app/components/app/app-publisher/types'
import type { App } from '@/types/app' import type { App } from '@/types/app'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react' import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store' import { useStore as useAppStore } from '@/app/components/app/store'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types' import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { consoleQuery } from '@/service/client' import { consoleQuery } from '@/service/client'
import { createConsoleQueryWrapper, seedFeatures } from '@/test/console/query-data'
import FeaturesTrigger from '../features-trigger' import FeaturesTrigger from '../features-trigger'
const mockUseIsChatMode = vi.fn() const mockUseIsChatMode = vi.fn()
@ -17,7 +18,6 @@ const mockUseChecklist = vi.fn()
const mockUseChecklistBeforePublish = vi.fn() const mockUseChecklistBeforePublish = vi.fn()
const mockUseNodesSyncDraft = vi.fn() const mockUseNodesSyncDraft = vi.fn()
const mockUseFeatures = vi.fn() const mockUseFeatures = vi.fn()
const mockUseProviderContext = vi.fn()
const mockUseNodes = vi.fn() const mockUseNodes = vi.fn()
const mockUseEdges = 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', () => ({ vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: Record<string, unknown>) => unknown) => mockUseFeatures(selector), 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', () => ({ vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
default: () => mockUseNodes(), default: () => mockUseNodes(),
})) }))
@ -253,23 +239,16 @@ vi.mock('@/hooks/use-theme', () => ({
// Use real app store - global zustand mock will auto-reset between tests // Use real app store - global zustand mock will auto-reset between tests
const createProviderContext = ({ const renderWithToast = (
type = 'sandbox', ui: ReactElement,
isFetchedPlan = true, { edition = 'CLOUD', plan = 'sandbox' }: { edition?: DeploymentEdition; plan?: CloudPlan } = {},
}: { ) => {
type?: CloudPlan const { queryClient, wrapper } = createConsoleQueryWrapper({
isFetchedPlan?: boolean systemFeatures: { deployment_edition: edition },
}) => ({ })
plan: { type }, seedFeatures(queryClient, { billing: { subscription: { plan } } })
isFetchedPlan, vi.spyOn(queryClient, 'invalidateQueries').mockImplementation(mockInvalidateQueries)
}) return { queryClient, ...render(ui, { wrapper }) }
const renderWithToast = (ui: ReactElement) => {
const queryClient = new QueryClient()
return {
queryClient,
...render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>),
}
} }
describe('FeaturesTrigger', () => { describe('FeaturesTrigger', () => {
@ -295,7 +274,6 @@ describe('FeaturesTrigger', () => {
mockUseFeatures.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => mockUseFeatures.mockImplementation((selector: (state: Record<string, unknown>) => unknown) =>
selector({ features: { file: {} } }), selector({ features: { file: {} } }),
) )
mockUseProviderContext.mockReturnValue(createProviderContext({}))
mockUseNodes.mockReturnValue([]) mockUseNodes.mockReturnValue([])
mockUseEdges.mockReturnValue([]) mockUseEdges.mockReturnValue([])
// Set up app store state // Set up app store state
@ -467,24 +445,32 @@ describe('FeaturesTrigger', () => {
}) })
}) })
it('should set startNodeLimitExceeded when sandbox entry limit is exceeded', () => { it.each([
// Arrange { edition: 'CLOUD', plan: 'sandbox', restricted: true },
mockUseNodes.mockReturnValue([ { edition: 'CLOUD', plan: 'professional', restricted: false },
{ id: 'start', data: { type: BlockEnum.Start } }, { edition: 'COMMUNITY', plan: 'sandbox', restricted: false },
{ id: 'trigger-1', data: { type: BlockEnum.TriggerWebhook } }, { edition: 'ENTERPRISE', plan: 'sandbox', restricted: false },
{ id: 'trigger-2', data: { type: BlockEnum.TriggerSchedule } }, ] as const)(
{ id: 'end', data: { type: BlockEnum.End } }, '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 // Act
renderWithToast(<FeaturesTrigger />) renderWithToast(<FeaturesTrigger />, { edition, plan })
// Assert // Assert
const publisher = screen.getByTestId('app-publisher') const publisher = screen.getByTestId('app-publisher')
expect(publisher).toHaveAttribute('data-start-node-limit-exceeded', 'true') expect(publisher).toHaveAttribute('data-start-node-limit-exceeded', String(restricted))
expect(publisher).toHaveAttribute('data-publish-disabled', 'true') expect(publisher).toHaveAttribute('data-publish-disabled', String(restricted))
expect(publisher).toHaveAttribute('data-has-trigger-node', 'true') expect(publisher).toHaveAttribute('data-has-trigger-node', 'true')
}) },
)
}) })
// Verifies callbacks wired from AppPublisher to stores and draft syncing. // Verifies callbacks wired from AppPublisher to stores and draft syncing.

View File

@ -9,7 +9,7 @@ import type { CommonEdgeType, Node } from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast' 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 { memo, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useEdges } from 'reactflow' 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 { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes' import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { BlockEnum, InputVarType, isTriggerNode } from '@/app/components/workflow/types' 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 useTheme from '@/hooks/use-theme'
import { fetchAppDetail } from '@/service/apps' import { fetchAppDetail } from '@/service/apps'
import { consoleQuery } from '@/service/client' import { consoleQuery } from '@/service/client'
@ -50,7 +50,16 @@ const FeaturesTrigger = () => {
const appID = appDetail?.id const appID = appDetail?.id
const { nodesReadOnly, getNodesReadOnly } = useNodesReadOnly() const { nodesReadOnly, getNodesReadOnly } = useNodesReadOnly()
const canReleaseAndVersion = useHooksStore((s) => s.accessControl.canReleaseAndVersion) 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 publishedAt = useStore((s) => s.publishedAt)
const draftUpdatedAt = useStore((s) => s.draftUpdatedAt) const draftUpdatedAt = useStore((s) => s.draftUpdatedAt)
const toolPublished = useStore((s) => s.toolPublished) const toolPublished = useStore((s) => s.toolPublished)
@ -135,8 +144,8 @@ const FeaturesTrigger = () => {
if (nodeType === BlockEnum.Start || isTriggerNode(nodeType)) return count + 1 if (nodeType === BlockEnum.Start || isTriggerNode(nodeType)) return count + 1
return count return count
}, 0) }, 0)
return isFetchedPlan && plan.type === 'sandbox' && entryCount > 2 return deploymentEdition === 'CLOUD' && plan === 'sandbox' && entryCount > 2
}, [nodes, plan.type, isFetchedPlan]) }, [nodes, plan, deploymentEdition])
const hasHumanInputNode = useMemo(() => { const hasHumanInputNode = useMemo(() => {
return nodes.some((node) => node.data.type === BlockEnum.HumanInput) return nodes.some((node) => node.data.type === BlockEnum.HumanInput)

View File

@ -187,7 +187,6 @@ const renderPanelElement = (data?: Partial<LLMNodeType>) => (
plugin_id: 'langgenius/openai', plugin_id: 'langgenius/openai',
} as unknown as ModelProviderSummaryResponse, } as unknown as ModelProviderSummaryResponse,
], ],
isFetchedPlan: true,
})} })}
> >
<Panel id="llm-node" data={{ ...baseNodeData, ...data }} panelProps={panelProps} /> <Panel id="llm-node" data={{ ...baseNodeData, ...data }} panelProps={panelProps} />

View File

@ -1,10 +1,11 @@
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen' import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useSuspenseQuery } from '@tanstack/react-query'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useCallback, useEffect, useRef, useState } from 'react' import { useAtomValue } from 'jotai'
import { NUM_INFINITE } from '@/app/components/billing/config' 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 { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { isServer } from '@/utils/client' import { consoleQuery } from '@/service/client'
type TriggerEventsLimitModalContent = { type TriggerEventsLimitModalContent = {
usage: number usage: number
@ -12,24 +13,6 @@ type TriggerEventsLimitModalContent = {
resetInDays?: number 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 = { type UseTriggerEventsLimitModalResult = {
triggerEventsLimitModal: TriggerEventsLimitModalContent | null triggerEventsLimitModal: TriggerEventsLimitModalContent | null
dismissTriggerEventsLimitModal: () => void dismissTriggerEventsLimitModal: () => void
@ -37,89 +20,72 @@ type UseTriggerEventsLimitModalResult = {
const TRIGGER_EVENTS_LOCALSTORAGE_PREFIX = 'trigger-events-limit-dismissed' const TRIGGER_EVENTS_LOCALSTORAGE_PREFIX = 'trigger-events-limit-dismissed'
export const useTriggerEventsLimitModal = ({ export const useTriggerEventsLimitModal = (): UseTriggerEventsLimitModalResult => {
plan, const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom)
isFetchedPlan,
currentWorkspaceId,
}: UseTriggerEventsLimitModalOptions): UseTriggerEventsLimitModalResult => {
const { data: deploymentEdition } = useSuspenseQuery({ const { data: deploymentEdition } = useSuspenseQuery({
...systemFeaturesQueryOptions(), ...systemFeaturesQueryOptions(),
select: ({ deployment_edition }) => deployment_edition, select: ({ deployment_edition }) => deployment_edition,
}) })
const [triggerEventsLimitModal, setTriggerEventsLimitModal] = const { data: quota } = useQuery(
useState<TriggerEventsLimitModalState | null>(null) consoleQuery.features.get.queryOptions({
const dismissedTriggerEventsLimitStorageKeysRef = useRef<Record<string, boolean>>({}) 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(() => { useEffect(() => {
if (deploymentEdition !== 'CLOUD') return if (!storageKey || dismissed !== undefined) return
if (isServer) return
if (!currentWorkspaceId) return
if (!isFetchedPlan) {
setTriggerEventsLimitModal(null)
return
}
const { type, usage, total, reset } = plan let storedDismissal = false
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
try { try {
if (localStorage.getItem(storageKey) === '1') hasDismissed = true storedDismissal = localStorage.getItem(storageKey) === '1'
} catch { } 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({ setDismissedCycles((current) => ({ ...current, [storageKey]: true }))
usage: usage.triggerEvents, try {
total: total.triggerEvents, localStorage.setItem(storageKey, '1')
resetInDays: triggerResetInDays, } catch {
storageKey, // The in-memory dismissal above also covers failed storage writes.
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
}
} }
dismissedTriggerEventsLimitStorageKeysRef.current[storageKey] = true }
setTriggerEventsLimitModal(null)
}, [triggerEventsLimitModal])
return { return {
triggerEventsLimitModal, triggerEventsLimitModal:
storageKey && dismissed === false && quota
? {
usage: quota.usage,
total: quota.limit,
resetInDays: quota.plan === 'professional' ? (resetInDays ?? undefined) : undefined,
}
: null,
dismissTriggerEventsLimitModal, dismissTriggerEventsLimitModal,
} }
} }

View File

@ -8,11 +8,8 @@ import type { UpdatePluginPayload } from '@/app/components/plugins/types'
import type { InputVar } from '@/app/components/workflow/types' import type { InputVar } from '@/app/components/workflow/types'
import type { ExternalDataTool } from '@/models/common' import type { ExternalDataTool } from '@/models/common'
import type { ModerationConfig, PromptVariable } from '@/models/debug' import type { ModerationConfig, PromptVariable } from '@/models/debug'
import { useAtomValue } from 'jotai'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { PluginCategoryEnum } from '@/app/components/plugins/types' 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 { usePricingModal } from '@/hooks/use-query-params'
import dynamic from '@/next/dynamic' import dynamic from '@/next/dynamic'
import { useTriggerEventsLimitModal } from './hooks/use-trigger-events-limit-modal' import { useTriggerEventsLimitModal } from './hooks/use-trigger-events-limit-modal'
@ -93,15 +90,8 @@ export const ModalContextProvider = ({ children }: ModalContextProviderProps) =>
> | null>(null) > | null>(null)
const [showUpdatePluginModal, setShowUpdatePluginModal] = const [showUpdatePluginModal, setShowUpdatePluginModal] =
useState<ModalState<UpdatePluginPayload> | null>(null) useState<ModalState<UpdatePluginPayload> | null>(null)
const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom)
const [showAnnotationFullModal, setShowAnnotationFullModal] = useState(false) const [showAnnotationFullModal, setShowAnnotationFullModal] = useState(false)
const { plan, isFetchedPlan } = useProviderContext() const { triggerEventsLimitModal, dismissTriggerEventsLimitModal } = useTriggerEventsLimitModal()
const { triggerEventsLimitModal, dismissTriggerEventsLimitModal } = useTriggerEventsLimitModal({
plan,
isFetchedPlan,
currentWorkspaceId,
})
const handleCancelModerationSettingModal = () => { const handleCancelModerationSettingModal = () => {
setShowModerationSettingModal(null) setShowModerationSettingModal(null)

View File

@ -1,12 +1,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, waitFor } from '@testing-library/react' import { act, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import dayjs from 'dayjs'
import * as React from 'react' import * as React from 'react'
import { defaultPlan } from '@/app/components/billing/config'
import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types' import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types'
import { useModalContextSelector } from '@/context/modal-context' import { useModalContextSelector } from '@/context/modal-context'
import { ModalContextProvider } from '@/context/modal-context-provider' 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 { render } from '@/test/console/render'
import { createNuqsTestWrapper } from '@/test/nuqs-testing' 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() const mockConsoleStateReader = vi.fn()
vi.mock('@/context/workspace-state', async () => { vi.mock('@/context/workspace-state', async () => {
@ -41,39 +36,6 @@ vi.mock('@/context/workspace-state', async () => {
return createWorkspaceStateModuleMock(() => mockConsoleStateReader()) 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 ModalBlockingState = () => {
const hasBlockingModalOpen = useModalContextSelector((state) => state.hasBlockingModalOpen) const hasBlockingModalOpen = useModalContextSelector((state) => state.hasBlockingModalOpen)
@ -117,10 +79,15 @@ const UpdatePluginTrigger = ({
) )
} }
const renderProvider = (children: React.ReactNode = <ModalBlockingState />) => { const renderProvider = (
const { wrapper: QueryWrapper } = createConsoleQueryWrapper({ children: React.ReactNode = <ModalBlockingState />,
systemFeatures: { deployment_edition: 'CLOUD' }, 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: NuqsWrapper } = createNuqsTestWrapper()
const wrapper = ({ children: wrapperChildren }: { children: React.ReactNode }) => ( const wrapper = ({ children: wrapperChildren }: { children: React.ReactNode }) => (
<QueryWrapper> <QueryWrapper>
@ -128,13 +95,15 @@ const renderProvider = (children: React.ReactNode = <ModalBlockingState />) => {
</QueryWrapper> </QueryWrapper>
) )
return render(<ModalContextProvider>{children}</ModalContextProvider>, { wrapper }) return {
queryClient,
...render(<ModalContextProvider>{children}</ModalContextProvider>, { wrapper }),
}
} }
describe('ModalContextProvider trigger events limit modal', () => { describe('ModalContextProvider trigger events limit modal', () => {
beforeEach(() => { beforeEach(() => {
mockConsoleStateReader.mockReset() mockConsoleStateReader.mockReset()
mockUseProviderContext.mockReset()
window.localStorage.clear() window.localStorage.clear()
mockConsoleStateReader.mockReturnValue({ mockConsoleStateReader.mockReturnValue({
currentWorkspace: { currentWorkspace: {
@ -147,23 +116,60 @@ describe('ModalContextProvider trigger events limit modal', () => {
vi.restoreAllMocks() 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 () => { it('opens the trigger events limit modal and persists dismissal in localStorage', async () => {
const plan = createPlan({ const features = {
type: 'professional', billing: { subscription: { plan: 'professional' as const } },
usage: { triggerEvents: 3000 }, trigger_event: { usage: 3000, limit: 3000, reset_date: dayjs().add(5, 'day').unix() },
total: { triggerEvents: 3000 }, }
reset: { triggerEvents: 5 },
})
mockUseProviderContext.mockReturnValue({
plan,
isFetchedPlan: true,
})
// Note: vitest.setup.ts replaces localStorage with a mock object that has vi.fn() methods // 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 // We need to spy on the mock's setItem, not Storage.prototype.setItem
const setItemSpy = vi.spyOn(localStorage, 'setItem') const setItemSpy = vi.spyOn(localStorage, 'setItem')
const user = userEvent.setup() const user = userEvent.setup()
renderProvider() renderProvider(undefined, features)
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
expect(screen.getAllByText('3000')).toHaveLength(2) 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 () => { it('relies on the in-memory guard when localStorage reads throw', async () => {
const plan = createPlan({ const features = {
type: 'professional', billing: { subscription: { plan: 'professional' as const } },
usage: { triggerEvents: 200 }, trigger_event: { usage: 200, limit: 200, reset_date: dayjs().add(3, 'day').unix() },
total: { triggerEvents: 200 }, }
reset: { triggerEvents: 3 },
})
mockUseProviderContext.mockReturnValue({
plan,
isFetchedPlan: true,
})
vi.spyOn(localStorage, 'getItem').mockImplementation(() => { vi.spyOn(localStorage, 'getItem').mockImplementation(() => {
throw new Error('Storage disabled') throw new Error('Storage disabled')
}) })
const setItemSpy = vi.spyOn(localStorage, 'setItem')
const user = userEvent.setup() const user = userEvent.setup()
const { rerender } = renderProvider() const { rerender } = renderProvider(undefined, features)
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) 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()) await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(screen.getByText('clear')).toBeInTheDocument() expect(screen.getByText('clear')).toBeInTheDocument()
expect(setItemSpy).not.toHaveBeenCalled()
}) })
it('falls back to the in-memory guard when localStorage.setItem fails', async () => { it('falls back to the in-memory guard when localStorage.setItem fails', async () => {
const plan = createPlan({ const features = {
type: 'professional', billing: { subscription: { plan: 'professional' as const } },
usage: { triggerEvents: 120 }, trigger_event: { usage: 120, limit: 120, reset_date: dayjs().add(2, 'day').unix() },
total: { triggerEvents: 120 }, }
reset: { triggerEvents: 2 },
})
mockUseProviderContext.mockReturnValue({
plan,
isFetchedPlan: true,
})
vi.spyOn(localStorage, 'setItem').mockImplementation(() => { vi.spyOn(localStorage, 'setItem').mockImplementation(() => {
throw new Error('Quota exceeded') throw new Error('Quota exceeded')
}) })
const user = userEvent.setup() const user = userEvent.setup()
const { rerender } = renderProvider() const { rerender } = renderProvider(undefined, features)
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) 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 () => { it('closes the trigger events limit modal and opens pricing when upgrading', async () => {
const plan = createPlan({ const features = {
type: 'professional', billing: { subscription: { plan: 'professional' as const } },
usage: { triggerEvents: 400 }, trigger_event: { usage: 400, limit: 400, reset_date: dayjs().add(6, 'day').unix() },
total: { triggerEvents: 400 }, }
reset: { triggerEvents: 6 },
})
mockUseProviderContext.mockReturnValue({
plan,
isFetchedPlan: true,
})
const user = userEvent.setup() const user = userEvent.setup()
renderProvider() renderProvider(undefined, features)
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
@ -277,16 +263,11 @@ describe('ModalContextProvider trigger events limit modal', () => {
describe('ModalContextProvider plugin update modal', () => { describe('ModalContextProvider plugin update modal', () => {
beforeEach(() => { beforeEach(() => {
mockConsoleStateReader.mockReset() mockConsoleStateReader.mockReset()
mockUseProviderContext.mockReset()
mockConsoleStateReader.mockReturnValue({ mockConsoleStateReader.mockReturnValue({
currentWorkspace: { currentWorkspace: {
id: 'workspace-1', id: 'workspace-1',
}, },
}) })
mockUseProviderContext.mockReturnValue({
plan: createPlan(),
isFetchedPlan: false,
})
}) })
it('keeps a model plugin update open until its refresh callback finishes', async () => { it('keeps a model plugin update open until its refresh callback finishes', async () => {

View File

@ -40,8 +40,6 @@ export const ProviderContextProvider = ({ children }: ProviderContextProviderPro
const features = featuresQuery.data const features = featuresQuery.data
const enableBilling = features?.billing.enabled ?? false const enableBilling = features?.billing.enabled ?? false
const plan = enableBilling && features ? parseCurrentPlan(features) : defaultPlan const plan = enableBilling && features ? parseCurrentPlan(features) : defaultPlan
const isFetchedPlan = featuresQuery.isSuccess && enableBilling
const isFetchedPlanInfo = featuresQuery.isFetched
const enableEducationPlan = features?.education.enabled ?? false const enableEducationPlan = features?.education.enabled ?? false
const enableSkill = features?.enable_skill ?? false const enableSkill = features?.enable_skill ?? false
const enableReplaceWebAppLogo = features?.can_replace_logo ?? false const enableReplaceWebAppLogo = features?.can_replace_logo ?? false
@ -90,8 +88,6 @@ export const ProviderContextProvider = ({ children }: ProviderContextProviderPro
), ),
supportRetrievalMethods: supportRetrievalMethods?.retrieval_method || [], supportRetrievalMethods: supportRetrievalMethods?.retrieval_method || [],
plan, plan,
isFetchedPlan,
isFetchedPlanInfo,
enableBilling, enableBilling,
enableSkill, enableSkill,
enableReplaceWebAppLogo, enableReplaceWebAppLogo,

View File

@ -26,8 +26,6 @@ export type ProviderContextState = {
total: UsagePlanInfo total: UsagePlanInfo
reset: UsageResetInfo reset: UsageResetInfo
} }
isFetchedPlan: boolean
isFetchedPlanInfo: boolean
enableBilling: boolean enableBilling: boolean
enableSkill: boolean enableSkill: boolean
enableReplaceWebAppLogo: boolean enableReplaceWebAppLogo: boolean
@ -49,8 +47,6 @@ export const baseProviderContextValue: ProviderContextState = {
supportRetrievalMethods: [], supportRetrievalMethods: [],
isAPIKeySet: true, isAPIKeySet: true,
plan: defaultPlan, plan: defaultPlan,
isFetchedPlan: false,
isFetchedPlanInfo: false,
enableBilling: false, enableBilling: false,
enableSkill: false, enableSkill: false,
enableReplaceWebAppLogo: false, enableReplaceWebAppLogo: false,