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": {
"eslint-react/set-state-in-effect": {
"count": 3
"count": 1
},
"no-restricted-globals": {
"count": 2

View File

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

View File

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

View File

@ -94,7 +94,6 @@ const setupContexts = (
mockProviderCtx = {
plan: createPlanData(planOverrides),
enableBilling: true,
isFetchedPlan: true,
enableEducationPlan: false,
...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 { 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' }} />)

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 { 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()
})

View File

@ -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'
}

View File

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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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' } })

View File

@ -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.

View File

@ -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)

View File

@ -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} />

View File

@ -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,
}
}

View File

@ -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)

View File

@ -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 () => {

View File

@ -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,

View File

@ -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,