refactor(web): isolate pricing modal state and lifecycle (#41960)

This commit is contained in:
yyh 2026-09-08 07:53:39 +00:00 committed by GitHub
parent a233b2f53a
commit 12d23062d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
72 changed files with 1268 additions and 869 deletions

View File

@ -5,9 +5,10 @@ import type {
import type { RenderOptions } from '@testing-library/react'
import type { ReactElement } from 'react'
import type { DeepPartial } from '@/test/console/system-features'
import { screen, within } from '@testing-library/react'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import dayjs from 'dayjs'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import * as React from 'react'
import AnnotationFull from '@/app/components/billing/annotation-full'
import AnnotationFullModal from '@/app/components/billing/annotation-full/modal'
@ -28,12 +29,18 @@ import {
} from '@/test/console/query-data'
import { render as renderWithConsoleState } from '@/test/console/render'
const onPricingUrlUpdate = vi.hoisted(() => vi.fn())
let mockFeatures: DeepPartial<GetFeaturesResponse> = {}
let mockVectorSpace: GetFeaturesVectorSpaceResponse = { size: 0, limit: 50, usage_unknown: false }
let mockConsoleState: Record<string, unknown> = {}
let mockEducationStatus = { is_student: false, allow_refresh: false, expire_at: null }
const render = (ui: ReactElement, options: RenderOptions = {}, vectorSpaceUsageUnknown = false) => {
const renderWithoutPricing = (
ui: ReactElement,
options: RenderOptions = {},
vectorSpaceUsageUnknown = false,
) => {
const queryClient = createConsoleQueryClient()
queryClient.setQueryData(consoleQuery.features.vectorSpace.get.queryOptions().queryKey, {
...mockVectorSpace,
@ -53,21 +60,14 @@ const render = (ui: ReactElement, options: RenderOptions = {}, vectorSpaceUsageU
return renderWithConsoleState(ui, { ...options, wrapper })
}
const mockSetShowPricingModal = vi.fn()
vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowPricingModal: mockSetShowPricingModal,
}),
}))
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en-US',
useGetPricingPageLanguage: () => 'en',
useLocale: () => 'en-US',
}))
// ─── Navigation mocks ───────────────────────────────────────────────────────
@ -118,6 +118,11 @@ const setupConsoleState = (overrides: Record<string, unknown> = {}) => {
// 1. Billing Page + Plan Component Integration
// Tests the full data flow: BillingPage → PlanComp → UsageInfo → ProgressBar
// ═══════════════════════════════════════════════════════════════════════════
function render(...args: Parameters<typeof renderWithoutPricing>) {
args[0] = <NuqsTestingAdapter onUrlUpdate={onPricingUrlUpdate}>{args[0]}</NuqsTestingAdapter>
return renderWithoutPricing(...args)
}
describe('Billing Page + Plan Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -329,7 +334,7 @@ describe('Plan Type Display Integration', () => {
// ═══════════════════════════════════════════════════════════════════════════
// 3. Upgrade Flow Integration
// Tests the flow: UpgradeBtn click → setShowPricingModal
// Tests the flow: UpgradeBtn click → pricing URL
// and PlanUpgradeModal → close + trigger pricing
// ═══════════════════════════════════════════════════════════════════════════
describe('Upgrade Flow Integration', () => {
@ -341,7 +346,7 @@ describe('Upgrade Flow Integration', () => {
// UpgradeBtn triggers pricing modal
describe('UpgradeBtn triggers pricing modal', () => {
it('should call setShowPricingModal when clicking premium badge upgrade button', async () => {
it('should open pricing when clicking premium badge upgrade button', async () => {
const user = userEvent.setup()
render(<UpgradeBtn />)
@ -349,10 +354,12 @@ describe('Upgrade Flow Integration', () => {
const badgeText = screen.getByText(/upgradeBtn\.encourage/i)
await user.click(badgeText)
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
await waitFor(() =>
expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('pricing')).toBe('open'),
)
})
it('should call setShowPricingModal when clicking plain upgrade button', async () => {
it('should open pricing when clicking plain upgrade button', async () => {
const user = userEvent.setup()
render(<UpgradeBtn isPlain />)
@ -360,10 +367,12 @@ describe('Upgrade Flow Integration', () => {
const button = screen.getByRole('button')
await user.click(button)
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
await waitFor(() =>
expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('pricing')).toBe('open'),
)
})
it('should use custom onClick when provided instead of setShowPricingModal', async () => {
it('should use custom onClick when provided instead of opening pricing', async () => {
const customOnClick = vi.fn()
const user = userEvent.setup()
@ -373,7 +382,7 @@ describe('Upgrade Flow Integration', () => {
await user.click(badgeText)
expect(customOnClick).toHaveBeenCalledTimes(1)
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
expect(onPricingUrlUpdate).not.toHaveBeenCalled()
})
it('should fire gtag event with loc parameter when clicked', async () => {
@ -393,7 +402,7 @@ describe('Upgrade Flow Integration', () => {
// PlanUpgradeModal integration: close modal and trigger pricing
describe('PlanUpgradeModal upgrade flow', () => {
it('should call onClose and setShowPricingModal when clicking upgrade button in modal', async () => {
it('should close the notice and open pricing when clicking upgrade button in modal', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
@ -417,31 +426,9 @@ describe('Upgrade Flow Integration', () => {
// Should close the current modal first
expect(onClose).toHaveBeenCalledTimes(1)
// Then open pricing modal
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
it('should call onClose and custom onUpgrade when provided', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
const onUpgrade = vi.fn()
render(
<PlanUpgradeModal
show={true}
onClose={onClose}
onUpgrade={onUpgrade}
title="Test"
description="Test"
/>,
await waitFor(() =>
expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('pricing')).toBe('open'),
)
const upgradeText = screen.getByText(/triggerLimitModal\.upgrade/i)
await user.click(upgradeText)
expect(onClose).toHaveBeenCalledTimes(1)
expect(onUpgrade).toHaveBeenCalledTimes(1)
// Custom onUpgrade replaces default setShowPricingModal
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
})
it('should call onClose when clicking dismiss button', async () => {
@ -454,7 +441,7 @@ describe('Upgrade Flow Integration', () => {
await user.click(dismissBtn)
expect(onClose).toHaveBeenCalledTimes(1)
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
expect(onPricingUrlUpdate).not.toHaveBeenCalled()
})
})
@ -469,7 +456,9 @@ describe('Upgrade Flow Integration', () => {
const upgradeText = screen.getByText(/upgradeBtn\.encourageShort/i)
await user.click(upgradeText)
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
await waitFor(() =>
expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('pricing')).toBe('open'),
)
})
})
})
@ -611,7 +600,6 @@ describe('Capacity Full Components Integration', () => {
<TriggerEventsLimitModal
show={true}
onClose={vi.fn()}
onUpgrade={vi.fn()}
usage={18000}
total={20000}
resetInDays={5}
@ -632,27 +620,20 @@ describe('Capacity Full Components Integration', () => {
expect(screen.getByText(/triggerLimitModal\.dismiss/i)).toBeInTheDocument()
})
it('should call onClose and onUpgrade when clicking upgrade', async () => {
it('closes the quota notice and opens pricing when upgrading', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
const onUpgrade = vi.fn()
setupBilling({ billing: { subscription: { plan: 'professional' } } })
render(
<TriggerEventsLimitModal
show={true}
onClose={onClose}
onUpgrade={onUpgrade}
usage={20000}
total={20000}
/>,
)
render(<TriggerEventsLimitModal show={true} onClose={onClose} usage={20000} total={20000} />)
const upgradeBtn = screen.getByText(/triggerLimitModal\.upgrade/i)
await user.click(upgradeBtn)
expect(onClose).toHaveBeenCalledTimes(1)
expect(onUpgrade).toHaveBeenCalledTimes(1)
await waitFor(() =>
expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('pricing')).toBe('open'),
)
})
})
})
@ -809,7 +790,9 @@ describe('Cross-Component Upgrade Flow', () => {
const upgradeText = screen.getByText(/upgradeBtn\.encourageShort/i)
await user.click(upgradeText)
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
await waitFor(() =>
expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('pricing')).toBe('open'),
)
})
it('should trigger pricing from VectorSpaceFull upgrade button', async () => {
@ -824,7 +807,9 @@ describe('Cross-Component Upgrade Flow', () => {
const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i)
await user.click(upgradeText)
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
await waitFor(() =>
expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('pricing')).toBe('open'),
)
})
it('should trigger pricing from AnnotationFull upgrade button', async () => {
@ -839,7 +824,9 @@ describe('Cross-Component Upgrade Flow', () => {
const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i)
await user.click(upgradeText)
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
await waitFor(() =>
expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('pricing')).toBe('open'),
)
})
it('should trigger pricing from TriggerEventsLimitModal through PlanUpgradeModal', async () => {
@ -847,18 +834,9 @@ describe('Cross-Component Upgrade Flow', () => {
const onClose = vi.fn()
setupBilling({ billing: { subscription: { plan: 'professional' } } })
render(
<TriggerEventsLimitModal
show={true}
onClose={onClose}
onUpgrade={vi.fn()}
usage={20000}
total={20000}
/>,
)
render(<TriggerEventsLimitModal show={true} onClose={onClose} usage={20000} total={20000} />)
// TriggerEventsLimitModal passes onUpgrade to PlanUpgradeModal
// PlanUpgradeModal's upgrade button calls onClose then onUpgrade
// PlanUpgradeModal dismisses the quota notice before opening pricing.
const upgradeBtn = screen.getByText(/triggerLimitModal\.upgrade/i)
await user.click(upgradeBtn)
@ -877,6 +855,8 @@ describe('Cross-Component Upgrade Flow', () => {
const upgradeText = screen.getByText(/upgradeBtn\.encourage$/i)
await user.click(upgradeText)
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
await waitFor(() =>
expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('pricing')).toBe('open'),
)
})
})

View File

@ -88,10 +88,9 @@ const renderCloudPlanItem = ({
<>
<ToastHost timeout={0} />
<CloudPlanItem
currentPlan={currentPlan}
plan={plan}
billingInterval={billingInterval}
isEducationDiscountEligible={isEducationDiscountEligible}
billing={{ currentPlan, isEducationDiscountEligible }}
/>
</>,
{ wrapper },

View File

@ -6,6 +6,7 @@ import type { RenderOptions } from '@testing-library/react'
import type { ReactElement } from 'react'
import type { DeepPartial } from '@/test/console/system-features'
import { cleanup, screen } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import * as React from 'react'
import PlanComp from '@/app/components/billing/plan'
import { consoleQuery } from '@/service/console'
@ -16,12 +17,14 @@ import {
} from '@/test/console/query-data'
import { render as renderWithConsoleState } from '@/test/console/render'
const onPricingUrlUpdate = vi.hoisted(() => vi.fn())
let mockFeatures: DeepPartial<GetFeaturesResponse> = {}
let mockVectorSpace: GetFeaturesVectorSpaceResponse = { size: 0, limit: 50, usage_unknown: false }
let mockConsoleState: Record<string, unknown> = {}
let mockEducationStatus = { is_student: false, allow_refresh: false, expire_at: null }
const render = (ui: ReactElement, options: RenderOptions = {}) => {
const renderWithoutPricing = (ui: ReactElement, options: RenderOptions = {}) => {
const queryClient = createConsoleQueryClient()
queryClient.setQueryData(consoleQuery.features.vectorSpace.get.queryOptions().queryKey, {
...mockVectorSpace,
@ -39,7 +42,6 @@ const render = (ui: ReactElement, options: RenderOptions = {}) => {
}
// ─── Mock state ──────────────────────────────────────────────────────────────
const mockSetShowPricingModal = vi.fn()
// ─── Context mocks ───────────────────────────────────────────────────────────
@ -47,11 +49,6 @@ vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => mockConsoleState)
})
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
setShowPricingModal: mockSetShowPricingModal,
}),
}))
// ─── Navigation mocks ───────────────────────────────────────────────────────
vi.mock('@/next/navigation', () => ({
@ -87,6 +84,11 @@ const setupBilling = (
}
// ═══════════════════════════════════════════════════════════════════════════════
function render(...args: Parameters<typeof renderWithoutPricing>) {
args[0] = <NuqsTestingAdapter onUrlUpdate={onPricingUrlUpdate}>{args[0]}</NuqsTestingAdapter>
return renderWithoutPricing(...args)
}
describe('Education Verification Flow', () => {
beforeEach(() => {
vi.clearAllMocks()

View File

@ -11,11 +11,22 @@
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
import { cleanup, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useQueryState } from 'nuqs'
import * as React from 'react'
import { ALL_PLANS } from '@/app/components/billing/config'
import { Pricing } from '@/app/components/billing/pricing'
import {
pricingQueryParamName,
pricingQueryParser,
} from '@/app/components/billing/pricing/query-params'
import { createConsoleQueryWrapper, seedFeatures } from '@/test/console/query-data'
import { render as renderWithConsoleState } from '@/test/console/render'
import { createNuqsTestWrapper } from '@/test/nuqs-testing'
function PricingEntry() {
const [, setPricing] = useQueryState(pricingQueryParamName, pricingQueryParser)
return <button onClick={() => setPricing('open')}>View pricing</button>
}
// ─── Mock state ──────────────────────────────────────────────────────────────
let mockConsoleState: Record<string, unknown> = {}
@ -24,7 +35,7 @@ let mockCurrentPlan: CloudPlan = 'sandbox'
let mockEducationEnabled = false
const mockGetSubscription = vi.hoisted(() => vi.fn())
const render = (ui: React.ReactElement) => {
const render = async (ui: React.ReactElement) => {
const { queryClient, wrapper } = createConsoleQueryWrapper({
accountProfile: mockConsoleState.userProfile as { email?: string },
accountProfileMeta: { currentVersion: '1.0.0' },
@ -36,7 +47,10 @@ const render = (ui: React.ReactElement) => {
},
education: { enabled: mockEducationEnabled },
})
return renderWithConsoleState(ui, { wrapper })
const { wrapper: NuqsWrapper } = createNuqsTestWrapper({ searchParams: '?pricing=open' })
const result = renderWithConsoleState(<NuqsWrapper>{ui}</NuqsWrapper>, { wrapper })
await screen.findByRole('heading', { name: 'billing.plans.sandbox.name' })
return result
}
// ─── Context mocks ───────────────────────────────────────────────────────────
@ -46,7 +60,7 @@ vi.mock('@/context/workspace-state', async () => {
})
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en-US',
useGetPricingPageLanguage: () => 'en',
useLocale: () => 'en-US',
}))
vi.mock('@/service/console', async (importOriginal) => {
@ -104,8 +118,6 @@ const setupContexts = (
// ═══════════════════════════════════════════════════════════════════════════════
describe('Pricing Modal Flow', () => {
const onCancel = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
cleanup()
@ -113,10 +125,31 @@ describe('Pricing Modal Flow', () => {
setupContexts()
})
it('starts a new pricing session after closing and reopening', async () => {
const user = userEvent.setup()
await render(
<>
<PricingEntry />
<Pricing />
</>,
)
await user.click(screen.getByRole('switch'))
expect(screen.getByRole('switch')).toBeChecked()
await user.click(screen.getByRole('tab', { name: 'billing.plansCommon.self' }))
await user.click(screen.getByRole('button', { name: 'common.operation.close' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
await user.click(screen.getByRole('button', { name: 'View pricing' }))
expect(await screen.findByRole('tab', { name: 'billing.plansCommon.cloud' })).toHaveAttribute(
'aria-selected',
'true',
)
expect(screen.getByRole('switch')).not.toBeChecked()
})
// ─── 1. Initial Rendering ────────────────────────────────────────────────
describe('Initial rendering', () => {
it('should render header with close button and footer with pricing link', () => {
render(<Pricing onCancel={onCancel} />)
it('should render header with close button and footer with pricing link', async () => {
await render(<Pricing />)
// Header close button exists (multiple plan buttons also exist)
const buttons = screen.getAllByRole('button')
@ -125,8 +158,8 @@ describe('Pricing Modal Flow', () => {
expect(screen.getByText(/plansCommon\.comparePlanAndFeatures/i)).toBeInTheDocument()
})
it('should default to cloud category with three cloud plans', () => {
render(<Pricing onCancel={onCancel} />)
it('should default to cloud category with three cloud plans', async () => {
await render(<Pricing />)
expect(screen.getByRole('tab', { name: 'billing.plansCommon.cloud' })).toHaveAttribute(
'aria-selected',
@ -146,32 +179,50 @@ describe('Pricing Modal Flow', () => {
expect(screen.getByText(/plans\.team\.name/i)).toBeInTheDocument()
})
it('should show plan range switcher (annual billing toggle) by default for cloud', () => {
render(<Pricing onCancel={onCancel} />)
it('should show plan range switcher (annual billing toggle) by default for cloud', async () => {
await render(<Pricing />)
expect(
screen.getByRole('switch', { name: 'billing.plansCommon.yearlyBilling' }),
screen.getByRole('switch', { name: /billing\.plansCommon\.annualBilling/ }),
).toBeInTheDocument()
expect(screen.getByText(/plansCommon\.annualBilling/i)).toBeInTheDocument()
})
it('should show the tax exclusion notice in the footer for cloud category', () => {
render(<Pricing onCancel={onCancel} />)
it('should show the tax exclusion notice in the footer for cloud category', async () => {
await render(<Pricing />)
expect(screen.getByText('billing.plansCommon.taxTip')).toBeInTheDocument()
})
})
it('tabs directly into the category controls and then the plan panel', async () => {
const user = userEvent.setup()
await render(<Pricing />)
screen.getByRole('button', { name: 'common.operation.close' }).focus()
await user.tab()
expect(screen.getByRole('tab', { name: 'billing.plansCommon.cloud' })).toHaveFocus()
await user.tab()
expect(
screen.getByRole('switch', { name: /billing\.plansCommon\.annualBilling/ }),
).toHaveFocus()
await user.tab()
expect(screen.getByRole('tabpanel', { name: 'billing.plansCommon.cloud' })).toHaveFocus()
})
// ─── 2. Category Switching ───────────────────────────────────────────────
describe('Category switching', () => {
it('should switch to self-hosted plans when clicking self-hosted tab', async () => {
it('allows arrow navigation before activating a category with Enter', async () => {
const user = userEvent.setup()
render(<Pricing onCancel={onCancel} />)
await render(<Pricing />)
const cloudTab = screen.getByRole('tab', { name: 'billing.plansCommon.cloud' })
const selfHostedTab = screen.getByRole('tab', { name: 'billing.plansCommon.self' })
cloudTab.focus()
await user.keyboard('{ArrowRight}')
expect(selfHostedTab).toHaveFocus()
expect(cloudTab).toHaveAttribute('aria-selected', 'true')
await user.keyboard('{Enter}')
await screen.findByRole('heading', { name: 'billing.plans.community.name' })
expect(selfHostedTab).toHaveAttribute('aria-selected', 'true')
@ -186,7 +237,7 @@ describe('Pricing Modal Flow', () => {
it('should hide plan range switcher for self-hosted category', async () => {
const user = userEvent.setup()
render(<Pricing onCancel={onCancel} />)
await render(<Pricing />)
await user.click(screen.getByRole('tab', { name: 'billing.plansCommon.self' }))
@ -196,7 +247,7 @@ describe('Pricing Modal Flow', () => {
it('should hide tax tip in footer for self-hosted category', async () => {
const user = userEvent.setup()
render(<Pricing onCancel={onCancel} />)
await render(<Pricing />)
await user.click(screen.getByRole('tab', { name: 'billing.plansCommon.self' }))
@ -205,7 +256,7 @@ describe('Pricing Modal Flow', () => {
it('should switch back to cloud plans when clicking cloud tab', async () => {
const user = userEvent.setup()
render(<Pricing onCancel={onCancel} />)
await render(<Pricing />)
// Switch to self-hosted
await user.click(screen.getByRole('tab', { name: 'billing.plansCommon.self' }))
@ -220,8 +271,8 @@ describe('Pricing Modal Flow', () => {
// ─── 3. Plan Range Switching (Monthly ↔ Yearly) ──────────────────────────
describe('Plan range switching', () => {
it('should show monthly prices by default', () => {
render(<Pricing onCancel={onCancel} />)
it('should show monthly prices by default', async () => {
await render(<Pricing />)
// Professional monthly price: $59
const proPriceStr = `$${ALL_PLANS.professional.price}`
@ -232,14 +283,14 @@ describe('Pricing Modal Flow', () => {
expect(screen.getByText(teamPriceStr)).toBeInTheDocument()
})
it('should show "Free" for sandbox plan regardless of range', () => {
render(<Pricing onCancel={onCancel} />)
it('should show "Free" for sandbox plan regardless of range', async () => {
await render(<Pricing />)
expect(screen.getByText(/plansCommon\.free/i)).toBeInTheDocument()
})
it('should show "most popular" badge only for professional plan', () => {
render(<Pricing onCancel={onCancel} />)
it('should show "most popular" badge only for professional plan', async () => {
await render(<Pricing />)
expect(screen.getByText(/plansCommon\.mostPopular/i)).toBeInTheDocument()
})
@ -249,7 +300,7 @@ describe('Pricing Modal Flow', () => {
describe('Cloud plan button states', () => {
it('should allow managers without billing permission keys to change plans', async () => {
const user = userEvent.setup()
render(<Pricing onCancel={onCancel} />)
await render(<Pricing />)
await user.click(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }))
@ -265,10 +316,10 @@ describe('Pricing Modal Flow', () => {
mockEducationEnabled = true
mockEducationStatus.is_student = true
const user = userEvent.setup()
render(<Pricing onCancel={onCancel} />)
await render(<Pricing />)
expect(
screen.getByRole('switch', { name: 'billing.plansCommon.yearlyBilling' }),
screen.getByRole('switch', { name: /billing\.plansCommon\.annualBilling/ }),
).toBeChecked()
await user.click(screen.getByRole('button', { name: 'education.useEducationDiscount' }))
@ -289,7 +340,7 @@ describe('Pricing Modal Flow', () => {
},
)
const user = userEvent.setup()
render(<Pricing onCancel={onCancel} />)
await render(<Pricing />)
await user.click(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' }))
@ -298,16 +349,16 @@ describe('Pricing Modal Flow', () => {
})
})
it('should show "Current Plan" for the current plan (sandbox)', () => {
it('should show "Current Plan" for the current plan (sandbox)', async () => {
setupContexts({ type: 'sandbox' })
render(<Pricing onCancel={onCancel} />)
await render(<Pricing />)
expect(screen.getByText(/plansCommon\.currentPlan/i)).toBeInTheDocument()
})
it('should show specific button text for non-current plans', () => {
it('should show specific button text for non-current plans', async () => {
setupContexts({ type: 'sandbox' })
render(<Pricing onCancel={onCancel} />)
await render(<Pricing />)
// Professional button text
expect(screen.getByText(/plansCommon\.startBuilding/i)).toBeInTheDocument()
@ -320,7 +371,7 @@ describe('Pricing Modal Flow', () => {
describe('Self-hosted plan details', () => {
it('should show "coming soon" text for premium plan cloud providers', async () => {
const user = userEvent.setup()
render(<Pricing onCancel={onCancel} />)
await render(<Pricing />)
await user.click(screen.getByText(/plansCommon\.self/i))
@ -330,13 +381,13 @@ describe('Pricing Modal Flow', () => {
// ─── 6. Pricing URL ─────────────────────────────────────────────────────
describe('Pricing page URL', () => {
it('should render pricing link with correct URL', () => {
render(<Pricing onCancel={onCancel} />)
it('should render pricing link with correct URL', async () => {
await render(<Pricing />)
const link = screen.getByText(/plansCommon\.comparePlanAndFeatures/i)
expect(link.closest('a')).toHaveAttribute(
'href',
'https://dify.ai/en/pricing#plans-and-features',
'https://dify.ai/pricing/dify-cloud#compare',
)
})
})

View File

@ -1,5 +1,6 @@
'use client'
import { Pricing } from '@/app/components/billing/pricing'
import { SettingsModal } from '@/app/components/header/account-setting/settings-modal'
import dynamic from '@/next/dynamic'
@ -27,6 +28,7 @@ export function CommonLayoutGlobalMounts() {
<GotoAnything />
<WorkflowGeneratorMount />
<SettingsModal />
<Pricing />
</>
)
}

View File

@ -2,7 +2,9 @@
import type { VersionHistory } from '@/types/workflow'
import { fireEvent, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { render as renderWithConsoleState } from '@/test/console/render'
import { AppModeEnum } from '@/types/app'
import { PublisherActionsSection } from '../built-in-publisher/actions-section'
import { PublisherSummarySection } from '../built-in-publisher/summary-section'
@ -629,3 +631,14 @@ describe('app-publisher sections', () => {
).toBeVisible()
})
})
function render(ui: React.ReactElement) {
const { wrapper: QueryWrapper } = createConsoleQueryWrapper()
return renderWithConsoleState(ui, {
wrapper: ({ children }) => (
<NuqsTestingAdapter>
<QueryWrapper>{children}</QueryWrapper>
</NuqsTestingAdapter>
),
})
}

View File

@ -1,26 +1,21 @@
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 { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useModalContext } from '@/context/modal-context'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { consoleQuery } from '@/service/console'
import { createConsoleQueryWrapper, seedFeatures } from '@/test/console/query-data'
import { render } from '@/test/console/render'
import { render as renderWithoutPricing } from '@/test/console/render'
import { RetentionUpgradeNotice } from '../retention-upgrade-notice'
vi.mock('@/context/modal-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/modal-context')>()
return {
...actual,
useModalContext: vi.fn(),
}
})
const onPricingUrlUpdate = vi.hoisted(() => vi.fn())
const mockUseModalContext = vi.mocked(useModalContext)
function render(...args: Parameters<typeof renderWithoutPricing>) {
args[0] = <NuqsTestingAdapter onUrlUpdate={onPricingUrlUpdate}>{args[0]}</NuqsTestingAdapter>
return renderWithoutPricing(...args)
}
describe('RetentionUpgradeNotice', () => {
const setShowPricingModal = vi.fn()
function renderNotice(
deploymentEdition: DeploymentEdition = 'CLOUD',
plan: CloudPlan | null = 'sandbox',
@ -40,9 +35,6 @@ describe('RetentionUpgradeNotice', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseModalContext.mockReturnValue({
setShowPricingModal,
} as unknown as ReturnType<typeof useModalContext>)
})
it('should show accessible upgrade guidance for Cloud sandbox workspaces', async () => {
@ -57,7 +49,9 @@ describe('RetentionUpgradeNotice', () => {
await user.click(
within(notice).getByRole('button', { name: 'billing.upgradeBtn.encourageShort' }),
)
expect(setShowPricingModal).toHaveBeenCalledOnce()
await waitFor(() =>
expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('pricing')).toBe('open'),
)
})
it.each([

View File

@ -1,13 +1,18 @@
import type { ReactElement, ReactNode } from 'react'
import type { ModalContextState } from '@/context/modal-context'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { consoleQuery } from '@/service/console'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import {
createConsoleQueryClient,
renderWithConsoleQuery as renderWithoutPricing,
} from '@/test/console/query-data'
import { AppModeEnum } from '@/types/app'
import SettingsModal from '../index'
const onPricingUrlUpdate = vi.hoisted(() => vi.fn())
let deploymentEdition: 'CLOUD' | 'COMMUNITY' = 'CLOUD'
let copyrightEnabled = true
@ -60,23 +65,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
}))
const mockOnClose = vi.fn()
const mockOnSave = vi.fn()
const mockSetShowPricingModal = vi.fn()
const buildModalContext = (): ModalContextState => ({
hasBlockingModalOpen: false,
setShowModerationSettingModal: vi.fn(),
setShowExternalDataToolModal: vi.fn(),
setShowPricingModal: mockSetShowPricingModal,
setShowAnnotationFullModal: vi.fn(),
setShowModelModal: vi.fn(),
setShowExternalKnowledgeAPIModal: vi.fn(),
setShowOpeningModal: vi.fn(),
setShowUpdatePluginModal: vi.fn(),
})
vi.mock('@/context/modal-context', () => ({
useModalContext: () => buildModalContext(),
}))
vi.mock('@/context/i18n', async () => {
const actual = await vi.importActual<typeof import('@/context/i18n')>('@/context/i18n')
@ -122,12 +110,21 @@ const renderSettingsModal = (appInfo = mockAppInfo, canDeploy = false) =>
const inputPlaceholderName = 'appOverview.overview.appInfo.settings.more.inputPlaceholder'
function render(...args: Parameters<typeof renderWithData>) {
const wrap = (ui: ReactElement) => (
<NuqsTestingAdapter onUrlUpdate={onPricingUrlUpdate}>{ui}</NuqsTestingAdapter>
)
args[0] = wrap(args[0])
const result = renderWithData(...args)
return { ...result, rerender: (ui: ReactElement) => result.rerender(wrap(ui)) }
}
describe('SettingsModal', () => {
beforeEach(() => {
toastMocks.call.mockClear()
mockOnClose.mockClear()
mockOnSave.mockClear()
mockSetShowPricingModal.mockClear()
onPricingUrlUpdate.mockClear()
deploymentEdition = 'CLOUD'
copyrightEnabled = true
})
@ -400,7 +397,9 @@ describe('SettingsModal', () => {
renderSettingsModal()
fireEvent.click((await screen.findAllByText('billing.upgradeBtn.encourageShort'))[0]!)
expect(mockSetShowPricingModal).toHaveBeenCalled()
await waitFor(() =>
expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('pricing')).toBe('open'),
)
})
it('should hide the upgrade badge for non-sandbox plans', async () => {
@ -460,10 +459,14 @@ describe('SettingsModal', () => {
})
})
function render(ui: ReactElement) {
return renderWithConsoleQuery(ui, {
function renderWithData(
ui: ReactElement,
options: Parameters<typeof renderWithoutPricing>[1] = {},
) {
return renderWithoutPricing(ui, {
systemFeatures: { deployment_edition: deploymentEdition },
features: { webapp_copyright_enabled: copyrightEnabled },
...options,
})
}
@ -474,10 +477,11 @@ it('saves unrelated settings while entitlements are pending without clearing pro
queryFn: () => new Promise(() => {}),
})
const onSave = vi.fn().mockResolvedValue(undefined)
renderWithConsoleQuery(
<SettingsModal isChat isShow appInfo={mockAppInfo} onClose={vi.fn()} onSave={onSave} />,
{ queryClient, systemFeatures: { deployment_edition: 'CLOUD' } },
)
render(<SettingsModal isChat isShow appInfo={mockAppInfo} onClose={vi.fn()} onSave={onSave} />, {
queryClient,
features: undefined,
systemFeatures: { deployment_edition: 'CLOUD' },
})
expect(screen.getByRole('textbox', { name: inputPlaceholderName })).toBeDisabled()
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))

View File

@ -1,4 +1,5 @@
'use client'
import type { FC } from 'react'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { AppIconType, Language, SiteConfig } from '@/types/app'
@ -30,6 +31,7 @@ import { toast } from '@langgenius/dify-ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useQueryState } from 'nuqs'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
@ -37,7 +39,10 @@ import AppIcon from '@/app/components/base/app-icon'
import AppIconPicker from '@/app/components/base/app-icon-picker'
import Divider from '@/app/components/base/divider'
import { PremiumBadgeButton } from '@/app/components/base/premium-badge'
import { useModalContext } from '@/context/modal-context'
import {
pricingQueryParamName,
pricingQueryParser,
} from '@/app/components/billing/pricing/query-params'
import { deploymentEditionAtom } from '@/features/system-features/state'
import { languages } from '@/i18n-config/language'
import Link from '@/next/link'
@ -212,7 +217,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
select: (data) => data.webapp_copyright_enabled,
}),
)
const { setShowPricingModal } = useModalContext()
const [, setPricing] = useQueryState(pricingQueryParamName, pricingQueryParser)
const canCustomizePlaceholder = deploymentEdition !== 'CLOUD' || webappCopyrightEnabled === true
const selectedLanguage = LANGUAGE_OPTIONS.find((item) => item.value === language)
const inputPlaceholderLabelId = React.useId()
@ -268,8 +273,8 @@ const SettingsModal: FC<ISettingsModalProps> = ({
if (nextLanguage) setLanguage(nextLanguage.value)
}
const handlePlanClick = useCallback(() => {
setShowPricingModal()
}, [setShowPricingModal])
setPricing('open')
}, [setPricing])
const shouldResetForm =
isShow && (!previousIsShow || settingsResetKey !== previousSettingsResetKey)

View File

@ -1,5 +1,6 @@
import type { Features } from '../../types'
import { screen } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { FeaturesProvider } from '../../context'
import NewFeaturePanel from '../index'
@ -88,20 +89,23 @@ const renderPanel = (
showFileUpload: boolean
showAnnotationReply: boolean
}> = {},
searchParams = '',
) => {
return renderWithConsoleQuery(
<FeaturesProvider features={defaultFeatures}>
<NewFeaturePanel
show={props.show ?? true}
isChatMode={props.isChatMode ?? true}
disabled={props.disabled ?? false}
onChange={props.onChange}
onClose={props.onClose ?? vi.fn()}
inWorkflow={props.inWorkflow}
showFileUpload={props.showFileUpload}
showAnnotationReply={props.showAnnotationReply}
/>
</FeaturesProvider>,
<NuqsTestingAdapter searchParams={searchParams}>
<FeaturesProvider features={defaultFeatures}>
<NewFeaturePanel
show={props.show ?? true}
isChatMode={props.isChatMode ?? true}
disabled={props.disabled ?? false}
onChange={props.onChange}
onClose={props.onClose ?? vi.fn()}
inWorkflow={props.inWorkflow}
showFileUpload={props.showFileUpload}
showAnnotationReply={props.showAnnotationReply}
/>
</FeaturesProvider>
</NuqsTestingAdapter>,
)
}
@ -111,6 +115,11 @@ describe('NewFeaturePanel', () => {
})
describe('Rendering', () => {
it('hides the feature drawer while pricing is open', () => {
renderPanel({ show: true }, '?pricing=open')
expect(screen.queryByText(/common\.featuresDescription/)).not.toBeInTheDocument()
})
it('should not render when show is false', () => {
renderPanel({ show: false })

View File

@ -3,6 +3,7 @@ import type { OnFeaturesChange } from '@/app/components/base/features/types'
import type { InputVar } from '@/app/components/workflow/types'
import type { PromptVariable } from '@/models/debug'
import { DrawerCloseButton } from '@langgenius/dify-ui/drawer'
import { useQueryState } from 'nuqs'
import { useTranslation } from 'react-i18next'
import AnnotationReply from '@/app/components/base/features/new-feature-panel/annotation-reply'
import Citation from '@/app/components/base/features/new-feature-panel/citation'
@ -15,6 +16,10 @@ import Moderation from '@/app/components/base/features/new-feature-panel/moderat
import MoreLikeThis from '@/app/components/base/features/new-feature-panel/more-like-this'
import SpeechToText from '@/app/components/base/features/new-feature-panel/speech-to-text'
import TextToSpeech from '@/app/components/base/features/new-feature-panel/text-to-speech'
import {
pricingQueryParamName,
pricingQueryParser,
} from '@/app/components/billing/pricing/query-params'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { useModalContext } from '@/context/modal-context'
@ -54,6 +59,7 @@ const NewFeaturePanel = ({
description,
drawerClassName,
}: Props) => {
const [pricing] = useQueryState(pricingQueryParamName, pricingQueryParser)
const { t } = useTranslation()
const { data: speech2textDefaultModel } = useDefaultModel(ModelTypeEnum.speech2text)
const { data: text2speechDefaultModel } = useDefaultModel(ModelTypeEnum.tts)
@ -61,7 +67,7 @@ const NewFeaturePanel = ({
return (
<FeaturePanelDrawer
show={show && !hasBlockingModalOpen}
show={show && !hasBlockingModalOpen && pricing !== 'open'}
onClose={onClose}
inWorkflow={inWorkflow}
className={drawerClassName}

View File

@ -1,11 +1,16 @@
'use client'
import type { ComponentType, ReactNode } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { useQueryState } from 'nuqs'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { UpgradeModal } from '@/app/components/base/upgrade-modal'
import {
pricingQueryParamName,
pricingQueryParser,
} from '@/app/components/billing/pricing/query-params'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import { useModalContext } from '@/context/modal-context'
import { SquareChecklist } from '../../base/icons/src/vender/other'
type Props = Readonly<{
@ -15,7 +20,6 @@ type Props = Readonly<{
extraInfo?: ReactNode
show: boolean
onClose: () => void
onUpgrade?: () => void
}>
export function PlanUpgradeModal({
@ -25,16 +29,14 @@ export function PlanUpgradeModal({
extraInfo,
show,
onClose,
onUpgrade,
}: Props) {
const { t } = useTranslation()
const { setShowPricingModal } = useModalContext()
const [, setPricing] = useQueryState(pricingQueryParamName, pricingQueryParser)
const handleUpgrade = useCallback(() => {
onClose()
if (onUpgrade) onUpgrade()
else setShowPricingModal()
}, [onClose, onUpgrade, setShowPricingModal])
setPricing('open')
}, [onClose, setPricing])
return (
<UpgradeModal

View File

@ -0,0 +1,225 @@
import type { GetFeaturesResponse } from '@dify/contracts/api/console/features/types.gen'
import { Dialog } from '@langgenius/dify-ui/dialog'
import { act, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { consoleQuery } from '@/service/console'
import {
createConsoleQueryClient,
createConsoleQueryWrapper,
seedFeatures,
} from '@/test/console/query-data'
import { render } from '@/test/console/render'
import { PricingContent } from '../content'
const openBillingWindow = vi.hoisted(() => vi.fn())
vi.mock('@/hooks/use-async-window-open', () => ({ useAsyncWindowOpen: () => openBillingWindow }))
vi.mock('@/context/i18n', () => ({ useGetLanguage: () => 'en-US', useLocale: () => 'en-US' }))
vi.mock('../plans/self-hosted-plan-item/list', () => ({ SelfHostedPlanFeatures: () => null }))
vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => ({ isCurrentWorkspaceManager: true }))
})
function setup() {
const queryClient = createConsoleQueryClient()
const { wrapper } = createConsoleQueryWrapper({ queryClient })
return {
queryClient,
show: () =>
render(
<Dialog>
<PricingContent />
</Dialog>,
{ wrapper },
),
}
}
it('shows prices and disables purchase buttons while features load', async () => {
const user = userEvent.setup()
const { queryClient, show } = setup()
let resolveFeatures!: (data: GetFeaturesResponse) => void
const request = queryClient.query({
...consoleQuery.features.get.queryOptions(),
queryFn: () =>
new Promise<GetFeaturesResponse>((resolve) => {
resolveFeatures = resolve
}),
})
show()
expect(screen.getByRole('status')).toHaveTextContent('appApi.loading')
expect(screen.getByRole('heading', { name: 'billing.plansCommon.title.plans' })).toBeVisible()
expect(
screen.getByRole('link', { name: 'billing.plansCommon.comparePlanAndFeatures' }),
).toBeVisible()
expect(screen.getByRole('heading', { name: 'billing.plans.professional.name' })).toBeVisible()
expect(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' })).toBeDisabled()
expect(screen.getByRole('switch')).not.toHaveAttribute('aria-disabled', 'true')
expect(screen.getByText('$59')).toBeVisible()
expect(screen.getByText('$159')).toBeVisible()
await user.click(screen.getByRole('switch'))
expect(screen.getByText('$590')).toBeVisible()
expect(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' })).toBeDisabled()
await user.click(screen.getByRole('tab', { name: 'billing.plansCommon.self' }))
expect(await screen.findByText('billing.plans.community.name')).toBeInTheDocument()
await act(async () => {
resolveFeatures(
seedFeatures(createConsoleQueryClient(), { billing: { subscription: { plan: 'team' } } }),
)
await request
})
await user.click(screen.getByRole('tab', { name: 'billing.plansCommon.cloud' }))
expect(await screen.findByText('billing.plans.team.name')).toBeInTheDocument()
expect(screen.getByRole('switch')).not.toHaveAttribute('aria-disabled', 'true')
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('preserves a billing interval selected before education eligibility arrives', async () => {
const user = userEvent.setup()
const { queryClient, show } = setup()
seedFeatures(queryClient, { education: { enabled: true } })
let resolveEducation!: (data: {
is_student: boolean
allow_refresh: boolean
expire_at: null
}) => void
const options = consoleQuery.account.education.get.queryOptions()
const request = queryClient.query({
...options,
queryFn: () =>
new Promise((resolve) => {
resolveEducation = resolve
}),
})
show()
expect(screen.getByRole('switch')).not.toHaveAttribute('aria-disabled', 'true')
expect(screen.getByRole('heading', { name: 'billing.plans.professional.name' })).toBeVisible()
expect(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' })).toBeDisabled()
await user.click(screen.getByRole('switch'))
await user.click(screen.getByRole('switch'))
await act(async () => {
resolveEducation({ is_student: true, allow_refresh: false, expire_at: null })
await request
})
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument())
expect(screen.getByRole('switch')).not.toBeChecked()
expect(screen.getByText('$59')).toBeVisible()
expect(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' })).toBeEnabled()
act(() =>
queryClient.setQueryData(options.queryKey, {
is_student: true,
allow_refresh: true,
expire_at: null,
}),
)
expect(screen.getByRole('switch')).not.toBeChecked()
})
it('shows yearly pricing immediately for a cached eligible education account', () => {
const { queryClient, show } = setup()
seedFeatures(queryClient, { education: { enabled: true } })
queryClient.setQueryData(consoleQuery.account.education.get.queryOptions().queryKey, {
is_student: true,
allow_refresh: false,
expire_at: null,
})
show()
expect(screen.getByRole('switch')).toBeChecked()
expect(screen.getByText('$590')).toBeVisible()
expect(screen.getByRole('button', { name: 'education.useEducationDiscount' })).toBeEnabled()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('keeps plan information visible after a request failure and restores billing on retry', async () => {
const user = userEvent.setup()
const { queryClient, show } = setup()
queryClient.setDefaultOptions({
queries: { retry: false, retryOnMount: false, staleTime: Infinity },
})
const features = seedFeatures(createConsoleQueryClient())
vi.spyOn(globalThis, 'fetch')
.mockRejectedValueOnce(new Error('Unavailable'))
.mockResolvedValue(
new Response(JSON.stringify(features), {
headers: { 'Content-Type': 'application/json' },
}),
)
show()
expect(await screen.findByRole('alert')).toHaveTextContent('common.error')
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(await screen.findByRole('heading', { name: 'billing.plans.sandbox.name' })).toBeVisible()
expect(
screen.queryByRole('button', { name: 'billing.plansCommon.currentPlan' }),
).not.toBeInTheDocument()
expect(screen.getByText('$59')).toBeVisible()
expect(screen.getByRole('button', { name: 'billing.plansCommon.startBuilding' })).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
expect(
await screen.findByRole('button', { name: 'billing.plansCommon.startBuilding' }),
).toBeEnabled()
})
afterEach(() => {
vi.restoreAllMocks()
openBillingWindow.mockReset()
})
it('keeps the current paid plan billing action available while education loads or fails', async () => {
const user = userEvent.setup()
const { queryClient, show } = setup()
queryClient.setDefaultOptions({
queries: { retry: false, retryOnMount: false, staleTime: Infinity },
})
seedFeatures(queryClient, {
billing: { subscription: { plan: 'professional' } },
education: { enabled: true },
})
let rejectEducation!: (error: Error) => void
const request = queryClient
.query({
...consoleQuery.account.education.get.queryOptions(),
queryFn: () =>
new Promise<never>((_, reject) => {
rejectEducation = reject
}),
})
.catch(() => {})
vi.spyOn(globalThis, 'fetch').mockImplementation(
async () =>
new Response(JSON.stringify({ url: 'https://billing.example.com' }), {
headers: { 'Content-Type': 'application/json' },
}),
)
openBillingWindow.mockImplementation((getUrl: () => Promise<string>) => getUrl())
show()
expect(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'billing.plansCommon.getStarted' })).toBeDisabled()
await user.click(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' }))
await waitFor(() => expect(openBillingWindow).toHaveResolvedWith('https://billing.example.com'))
await act(async () => {
rejectEducation(new Error('Unavailable'))
await request
})
expect(await screen.findByRole('alert')).toHaveTextContent('common.error')
expect(screen.getByRole('button', { name: 'billing.plansCommon.currentPlan' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'billing.plansCommon.getStarted' })).toBeDisabled()
})
it('uses the visible billing label to name and toggle the switch', async () => {
const user = userEvent.setup()
const { queryClient, show } = setup()
seedFeatures(queryClient)
show()
const billingSwitch = screen.getByRole('switch', { name: /billing\.plansCommon\.annualBilling/ })
expect(billingSwitch).toHaveAccessibleName(
screen.getByText(/billing\.plansCommon\.annualBilling/).textContent!,
)
expect(billingSwitch).not.toBeChecked()
await user.click(screen.getByText(/billing\.plansCommon\.annualBilling/))
expect(billingSwitch).toBeChecked()
expect(screen.getByText('$590')).toBeVisible()
await user.click(screen.getByText(/billing\.plansCommon\.annualBilling/))
expect(billingSwitch).not.toBeChecked()
expect(screen.getByText('$59')).toBeVisible()
})

View File

@ -1,43 +1,106 @@
import type { Mock } from 'vite-plus/test'
import { screen } from '@testing-library/react'
import { act, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useGetPricingPageLanguage } from '@/context/i18n'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { render } from '@/test/console/render'
import { useQueryState } from 'nuqs'
import { renderWithNuqs } from '@/test/nuqs-testing'
import Header from '../header'
import { Pricing } from '../index'
import { pricingQueryParamName, pricingQueryParser } from '../query-params'
let mockConsoleState: Record<string, unknown> = {}
const dialogModule = vi.hoisted(() => ({ ready: Promise.resolve() }))
vi.mock('../content', () => ({
PricingContent: () => <div>pricing-content</div>,
}))
vi.mock('@/context/workspace-state', async () => {
const { createWorkspaceStateModuleMock } = await import('@/test/console/state-fixture')
return createWorkspaceStateModuleMock(() => mockConsoleState)
vi.mock('../dialog-content', async (importOriginal) => {
await dialogModule.ready
return importOriginal()
})
vi.mock('@/context/i18n', () => ({
useGetPricingPageLanguage: vi.fn(),
vi.mock('@/context/i18n', () => ({ useLocale: () => 'en-US' }))
vi.mock('../content', () => ({
PricingContent: () => (
<>
<Header />
<p>Plans loaded</p>
</>
),
}))
describe('Pricing dialog lifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
mockConsoleState = {
isCurrentWorkspaceManager: true,
}
;(useGetPricingPageLanguage as Mock).mockReturnValue('en')
function PricingEntry() {
const [, setPricing] = useQueryState(pricingQueryParamName, pricingQueryParser)
return <button onClick={() => setPricing('open')}>Upgrade</button>
}
function CancelPricing() {
const [, setPricing] = useQueryState(pricingQueryParamName, pricingQueryParser)
return <button onClick={() => setPricing(null)}>Cancel opening</button>
}
describe('Pricing URL dialog', () => {
it.each(['', '?pricing=closed'])('stays closed for %s', (searchParams) => {
renderWithNuqs(<Pricing />, { searchParams })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.queryByText('Plans loaded')).not.toBeInTheDocument()
})
it('should call onCancel when the pricing dialog is closed', async () => {
it('does not open after the URL is cleared while the dialog module loads', async () => {
const user = userEvent.setup()
const onCancel = vi.fn()
const { wrapper } = createConsoleQueryWrapper()
render(<Pricing onCancel={onCancel} />, { wrapper })
let resolveModule!: () => void
dialogModule.ready = new Promise<void>((resolve) => {
resolveModule = resolve
})
const { onUrlUpdate } = renderWithNuqs(
<>
<PricingEntry />
<CancelPricing />
<Pricing />
</>,
)
await user.click(screen.getByRole('button', { name: 'Upgrade' }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Cancel opening' }))
await act(async () => {
resolveModule()
await dialogModule.ready
})
await waitFor(() =>
expect(onUrlUpdate.mock.lastCall?.[0].searchParams.has('pricing')).toBe(false),
)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Cancel opening' })).toHaveFocus()
})
it('opens from another owner and clears only its parameter when closed', async () => {
const user = userEvent.setup()
const { onUrlUpdate } = renderWithNuqs(
<>
<PricingEntry />
<Pricing />
</>,
{ searchParams: '?settings=billing' },
)
await user.click(screen.getByRole('button', { name: 'Upgrade' }))
const dialog = await screen.findByRole('dialog', { name: 'billing.plansCommon.title.plans' })
await screen.findByText('Plans loaded')
expect(screen.getByRole('heading', { name: 'billing.plansCommon.title.plans' })).toBeVisible()
expect(dialog).toHaveAccessibleDescription('billing.plansCommon.title.description')
expect(screen.queryByRole('button', { name: 'Upgrade' })).not.toBeInTheDocument()
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('pricing')).toBe('open')
expect(onUrlUpdate.mock.lastCall?.[0].options.history).toBe('replace')
await user.click(screen.getByRole('button', { name: 'common.operation.close' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(screen.getByRole('button', { name: 'Upgrade' })).toBeInTheDocument()
await waitFor(() => expect(screen.getByRole('button', { name: 'Upgrade' })).toHaveFocus())
expect(onUrlUpdate.mock.lastCall?.[0].searchParams.has('pricing')).toBe(false)
expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('settings')).toBe('billing')
expect(onUrlUpdate.mock.lastCall?.[0].options.history).toBe('replace')
})
expect(onCancel).toHaveBeenCalledTimes(1)
it('opens directly from the URL and closes with Escape', async () => {
const user = userEvent.setup()
const { onUrlUpdate } = renderWithNuqs(<Pricing />, { searchParams: '?pricing=open' })
await screen.findByRole('dialog')
await user.keyboard('{Escape}')
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(onUrlUpdate.mock.lastCall?.[0].searchParams.has('pricing')).toBe(false)
})
})

View File

@ -0,0 +1,21 @@
import type { Locale } from '@/i18n-config/language'
import { render, screen } from '@testing-library/react'
import { PricingFooter } from '../footer'
let locale: Locale = 'en-US'
vi.mock('@/context/i18n', () => ({ useLocale: () => locale }))
it.each<[Locale, string]>([
['en-US', ''],
['zh-Hans', '/zh'],
['ja-JP', '/ja'],
['ko-KR', '/ko'],
['de-DE', ''],
])('links to the current website comparison pages for %s', (language, prefix) => {
locale = language
const { rerender } = render(<PricingFooter category="cloud" />)
const link = screen.getByRole('link', { name: 'billing.plansCommon.comparePlanAndFeatures' })
expect(link).toHaveAttribute('href', `https://dify.ai${prefix}/pricing/dify-cloud#compare`)
rerender(<PricingFooter category="self-hosted" />)
expect(link).toHaveAttribute('href', `https://dify.ai${prefix}/pricing/dify-enterprise#compare`)
})

View File

@ -1,14 +1,8 @@
type CloudProps = {
isActive: boolean
}
const Cloud = ({ isActive }: CloudProps) => {
const color = isActive ? 'var(--color-saas-dify-blue-accessible)' : 'var(--color-text-primary)'
const Cloud = () => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="17" viewBox="0 0 16 17" fill="none">
<g clipPath="url(#clip0_1_4630)">
<rect y="0.5" width="4" height="4" rx="2" fill={color} />
<rect y="0.5" width="4" height="4" rx="2" fill="currentColor" />
<rect
opacity="0.18"
x="6"
@ -18,7 +12,7 @@ const Cloud = ({ isActive }: CloudProps) => {
rx="2"
fill="var(--color-text-quaternary)"
/>
<rect x="12" y="0.5" width="4" height="4" rx="2" fill={color} />
<rect x="12" y="0.5" width="4" height="4" rx="2" fill="currentColor" />
<rect
opacity="0.18"
y="6.5"
@ -27,7 +21,7 @@ const Cloud = ({ isActive }: CloudProps) => {
rx="2"
fill="var(--color-text-quaternary)"
/>
<rect x="6" y="6.5" width="4" height="4" rx="2" fill={color} />
<rect x="6" y="6.5" width="4" height="4" rx="2" fill="currentColor" />
<rect
opacity="0.18"
x="12"
@ -37,7 +31,7 @@ const Cloud = ({ isActive }: CloudProps) => {
rx="2"
fill="var(--color-text-quaternary)"
/>
<rect y="12.5" width="4" height="4" rx="2" fill={color} />
<rect y="12.5" width="4" height="4" rx="2" fill="currentColor" />
<rect
opacity="0.18"
x="6"
@ -47,7 +41,7 @@ const Cloud = ({ isActive }: CloudProps) => {
rx="2"
fill="var(--color-text-quaternary)"
/>
<rect x="12" y="12.5" width="4" height="4" rx="2" fill={color} />
<rect x="12" y="12.5" width="4" height="4" rx="2" fill="currentColor" />
</g>
<defs>
<clipPath id="clip0_1_4630">

View File

@ -1,10 +1,4 @@
type SelfHostedProps = {
isActive: boolean
}
const SelfHosted = ({ isActive }: SelfHostedProps) => {
const color = isActive ? 'var(--color-saas-dify-blue-accessible)' : 'var(--color-text-primary)'
const SelfHosted = () => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="17" viewBox="0 0 16 17" fill="none">
<g clipPath="url(#clip0_1_4644)">
@ -16,7 +10,7 @@ const SelfHosted = ({ isActive }: SelfHostedProps) => {
rx="2"
fill="var(--color-text-quaternary)"
/>
<rect x="6" y="0.5" width="4" height="4" rx="2" fill={color} />
<rect x="6" y="0.5" width="4" height="4" rx="2" fill="currentColor" />
<rect
opacity="0.18"
x="12"
@ -26,7 +20,7 @@ const SelfHosted = ({ isActive }: SelfHostedProps) => {
rx="2"
fill="var(--color-text-quaternary)"
/>
<rect y="6.5" width="4" height="4" rx="2" fill={color} />
<rect y="6.5" width="4" height="4" rx="2" fill="currentColor" />
<rect
opacity="0.18"
x="6"
@ -36,7 +30,7 @@ const SelfHosted = ({ isActive }: SelfHostedProps) => {
rx="2"
fill="var(--color-text-quaternary)"
/>
<rect x="12" y="6.5" width="4" height="4" rx="2" fill={color} />
<rect x="12" y="6.5" width="4" height="4" rx="2" fill="currentColor" />
<rect
opacity="0.18"
y="12.5"
@ -45,7 +39,7 @@ const SelfHosted = ({ isActive }: SelfHostedProps) => {
rx="2"
fill="var(--color-text-quaternary)"
/>
<rect x="6" y="12.5" width="4" height="4" rx="2" fill={color} />
<rect x="6" y="12.5" width="4" height="4" rx="2" fill="currentColor" />
<rect
opacity="0.18"
x="12"

View File

@ -1,5 +1,6 @@
import type { GetBillingSubscriptionData } from '@dify/contracts/api/console/billing/types.gen'
import { cn } from '@langgenius/dify-ui/cn'
import { Button } from '@langgenius/dify-ui/button'
import { Field, FieldLabel } from '@langgenius/dify-ui/field'
import { Switch } from '@langgenius/dify-ui/switch'
import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs'
import { useQuery } from '@tanstack/react-query'
@ -20,31 +21,42 @@ import { SelfHostedPlanItem } from './plans/self-hosted-plan-item'
type BillingInterval = GetBillingSubscriptionData['query']['interval']
export function PricingContent({ pricingPageURL }: { pricingPageURL: string }) {
export function PricingContent() {
const { t } = useTranslation()
const { data: features } = useQuery(consoleQuery.features.get.queryOptions())
const featuresQuery = useQuery(consoleQuery.features.get.queryOptions())
const { data: features } = featuresQuery
const educationEnabled = features?.education.enabled ?? false
const { data: isEducationAccount = false } = useQuery(
const educationQuery = useQuery(
consoleQuery.account.education.get.queryOptions({
enabled: educationEnabled,
select: ({ is_student }) => is_student ?? false,
}),
)
const canManageBilling = useAtomValue(isCurrentWorkspaceManagerAtom)
const isEducationDiscountEligible = educationEnabled && isEducationAccount
const isEducationDiscountEligible = educationEnabled ? educationQuery.data : false
const isCheckoutReady = features !== undefined && isEducationDiscountEligible !== undefined
const pricingError =
(!features && featuresQuery.isError) ||
(educationEnabled && educationQuery.data === undefined && educationQuery.isError)
const defaultBillingInterval: BillingInterval =
canManageBilling && isEducationDiscountEligible ? 'year' : 'month'
const [activeCategory, setActiveCategory] = React.useState<'cloud' | 'self-hosted'>('cloud')
const [selectedBillingInterval, setSelectedBillingInterval] = React.useState<BillingInterval>()
const billingInterval = selectedBillingInterval ?? defaultBillingInterval
const isCloud = activeCategory === 'cloud'
const currentCloudPlan = features?.billing.subscription.plan ?? 'sandbox'
const currentCloudPlan = features?.billing.subscription.plan
const billing = currentCloudPlan
? {
currentPlan: currentCloudPlan,
isEducationDiscountEligible,
}
: undefined
return (
<Tabs
defaultValue="cloud"
value={activeCategory}
onValueChange={setActiveCategory}
className="relative grid min-h-full grid-rows-[1fr_auto_auto_1fr] overflow-hidden"
className="relative grid min-h-full grid-rows-[1fr_auto_auto_1fr] overflow-clip"
>
<div className="absolute inset-x-0 -top-12 -z-10">
<NoiseTop />
@ -55,60 +67,40 @@ export function PricingContent({ pricingPageURL }: { pricingPageURL: string }) {
<div className="flex w-full justify-center border-t border-divider-accent px-10">
<div className="flex max-w-[1680px] grow items-center justify-between border-x border-divider-accent p-1">
<TabsList
activateOnFocus
aria-label={t(($) => $['plansCommon.title.plans'], { ns: 'billing' })}
className="items-center gap-0"
>
<TabsTab
value="cloud"
className="appearance-none justify-center gap-x-2 border-b-0 px-5 py-3 outline-hidden data-active:border-transparent"
render={(props, { active }) => (
<button {...props}>
<Cloud isActive={active} />
<span
className={cn(
'system-xl-semibold text-text-secondary',
active && 'text-saas-dify-blue-accessible',
)}
>
{t(($) => $['plansCommon.cloud'], { ns: 'billing' })}
</span>
</button>
)}
/>
className="appearance-none justify-center gap-x-2 border-b-0 px-5 py-3 system-xl-semibold text-text-secondary hover:text-saas-dify-blue-accessible data-active:border-transparent data-active:text-saas-dify-blue-accessible"
>
<Cloud />
{t(($) => $['plansCommon.cloud'], { ns: 'billing' })}
</TabsTab>
<Divider type="vertical" className="mx-2 h-4 bg-divider-accent" />
<TabsTab
value="self-hosted"
className="appearance-none justify-center gap-x-2 border-b-0 px-5 py-3 outline-hidden data-active:border-transparent"
render={(props, { active }) => (
<button {...props}>
<SelfHosted isActive={active} />
<span
className={cn(
'system-xl-semibold text-text-secondary',
active && 'text-saas-dify-blue-accessible',
)}
>
{t(($) => $['plansCommon.self'], { ns: 'billing' })}
</span>
</button>
)}
/>
className="appearance-none justify-center gap-x-2 border-b-0 px-5 py-3 system-xl-semibold text-text-secondary hover:text-saas-dify-blue-accessible data-active:border-transparent data-active:text-saas-dify-blue-accessible"
>
<SelfHosted />
{t(($) => $['plansCommon.self'], { ns: 'billing' })}
</TabsTab>
</TabsList>
{isCloud && (
<div className="flex items-center justify-end gap-x-3 pr-5">
<Switch
aria-label={t(($) => $['plansCommon.yearlyBilling'], { ns: 'billing' })}
size="lg"
checked={billingInterval === 'year'}
onCheckedChange={(checked) =>
setSelectedBillingInterval(checked ? 'year' : 'month')
}
/>
<span className="system-md-regular text-text-tertiary">
{t(($) => $['plansCommon.annualBilling'], { ns: 'billing', percent: 17 })}
</span>
</div>
<Field>
<FieldLabel className="flex items-center justify-end gap-x-3 pr-5">
<Switch
size="lg"
checked={billingInterval === 'year'}
onCheckedChange={(checked) =>
setSelectedBillingInterval(checked ? 'year' : 'month')
}
/>
<span className="system-md-regular text-text-tertiary">
{t(($) => $['plansCommon.annualBilling'], { ns: 'billing', percent: 17 })}
</span>
</FieldLabel>
</Field>
)}
</div>
</div>
@ -116,28 +108,35 @@ export function PricingContent({ pricingPageURL }: { pricingPageURL: string }) {
<div className="flex w-full justify-center border-t border-divider-accent px-10">
<TabsPanel
value="cloud"
className="flex max-w-[1680px] grow border-x border-divider-accent"
className="flex max-w-[1680px] grow flex-wrap border-x border-divider-accent"
>
<CloudPlanItem
currentPlan={currentCloudPlan}
plan="sandbox"
billingInterval={billingInterval}
isEducationDiscountEligible={isEducationDiscountEligible}
/>
{pricingError ? (
<div
role="alert"
className="flex w-full items-center justify-center gap-3 border-b border-divider-accent p-3"
>
<p>{t(($) => $.error, { ns: 'common' })}</p>
<Button
onClick={() => {
if (!features) void featuresQuery.refetch()
else void educationQuery.refetch()
}}
>
{t(($) => $['operation.retry'], { ns: 'common' })}
</Button>
</div>
) : (
!isCheckoutReady && (
<span role="status" className="sr-only">
{t(($) => $.loading, { ns: 'appApi' })}
</span>
)
)}
<CloudPlanItem plan="sandbox" billingInterval={billingInterval} billing={billing} />
<Divider type="vertical" className="mx-0 shrink-0 bg-divider-accent" />
<CloudPlanItem
currentPlan={currentCloudPlan}
plan="professional"
billingInterval={billingInterval}
isEducationDiscountEligible={isEducationDiscountEligible}
/>
<CloudPlanItem plan="professional" billingInterval={billingInterval} billing={billing} />
<Divider type="vertical" className="mx-0 shrink-0 bg-divider-accent" />
<CloudPlanItem
currentPlan={currentCloudPlan}
plan="team"
billingInterval={billingInterval}
isEducationDiscountEligible={isEducationDiscountEligible}
/>
<CloudPlanItem plan="team" billingInterval={billingInterval} billing={billing} />
</TabsPanel>
<TabsPanel
value="self-hosted"
@ -151,7 +150,7 @@ export function PricingContent({ pricingPageURL }: { pricingPageURL: string }) {
</TabsPanel>
</div>
<PricingFooter pricingPageURL={pricingPageURL} category={activeCategory} />
<PricingFooter category={activeCategory} />
<div className="absolute inset-x-0 -bottom-12 -z-10">
<NoiseBottom />

View File

@ -0,0 +1,53 @@
'use client'
import { DialogBackdrop, DialogClose, DialogPopup } from '@langgenius/dify-ui/dialog'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import {
ScrollArea,
ScrollAreaContent,
ScrollAreaCorner,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
} from '@langgenius/dify-ui/scroll-area'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { PricingContent } from './content'
export function PricingDialogContent() {
const { t } = useTranslation()
return (
<>
<DialogBackdrop className="transition-none" />
<DialogPopup className="fixed inset-0 size-full max-h-none max-w-none overflow-hidden rounded-none border-none bg-saas-background p-0 shadow-none transition-none data-ending-style:scale-100 data-ending-style:opacity-100 data-starting-style:scale-100 data-starting-style:opacity-100">
<DialogClose
render={
<IconButton
variant="secondary"
size="xl"
className="absolute inset-e-5.5 top-6 z-10 rounded-full"
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
>
<span aria-hidden="true" className="i-ri-close-line size-5" />
</IconButton>
}
/>
<ScrollArea className="h-full w-full overflow-hidden">
<ScrollAreaViewport tabIndex={-1} className="overscroll-contain">
<ScrollAreaContent className="grid min-h-full min-w-300">
<PricingContent />
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
<ScrollAreaScrollbar orientation="horizontal">
<ScrollAreaThumb />
</ScrollAreaScrollbar>
<ScrollAreaCorner className="bg-saas-background" />
</ScrollArea>
</DialogPopup>
</>
)
}

View File

@ -1,32 +1,37 @@
import type { Locale } from '@/i18n-config/language'
import { useTranslation } from 'react-i18next'
import { useLocale } from '@/context/i18n'
import Link from '@/next/link'
export function PricingFooter({
pricingPageURL,
category,
}: {
pricingPageURL: string
category: 'cloud' | 'self-hosted'
}) {
const websiteLocalePaths: Partial<Record<Locale, string>> = {
'zh-Hans': '/zh',
'ja-JP': '/ja',
'ko-KR': '/ko',
}
export function PricingFooter({ category }: { category: 'cloud' | 'self-hosted' }) {
const locale = useLocale()
const comparisonPage = category === 'cloud' ? 'dify-cloud' : 'dify-enterprise'
const pricingPageURL = `https://dify.ai${websiteLocalePaths[locale] ?? ''}/pricing/${comparisonPage}#compare`
const { t } = useTranslation()
return (
<div className="flex min-h-16 w-full justify-center border-t border-divider-accent px-10">
<div
data-category={category}
className="flex max-w-[1680px] grow justify-end border-x border-divider-accent p-6 data-[category=cloud]:justify-between"
className="flex max-w-[1680px] grow justify-end gap-6 border-x border-divider-accent p-6 data-[category=cloud]:justify-between"
>
{category === 'cloud' && (
<div className="flex flex-col text-text-tertiary">
<div className="flex min-w-0 flex-1 flex-col text-text-tertiary">
<span className="system-xs-regular">
{t(($) => $['plansCommon.taxTip'], { ns: 'billing' })}
</span>
</div>
)}
<span className="flex h-fit items-center gap-x-1 text-saas-dify-blue-accessible">
<span className="flex h-fit shrink-0 items-center gap-x-1 text-saas-dify-blue-accessible">
<Link
href={pricingPageURL}
className="system-md-regular hover:underline focus-visible:underline focus-visible:outline-hidden"
className="rounded-xs system-md-regular hover:underline focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
target="_blank"
rel="noopener noreferrer"
>

View File

@ -1,61 +1,29 @@
'use client'
import { Dialog, DialogClose, DialogContent } from '@langgenius/dify-ui/dialog'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import {
ScrollArea,
ScrollAreaContent,
ScrollAreaCorner,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
} from '@langgenius/dify-ui/scroll-area'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { useGetPricingPageLanguage } from '@/context/i18n'
import { PricingContent } from './content'
export function Pricing({ onCancel }: { onCancel: () => void }) {
const { t } = useTranslation()
const pricingPageLanguage = useGetPricingPageLanguage()
const pricingPageURL = pricingPageLanguage
? `https://dify.ai/${pricingPageLanguage}/pricing#plans-and-features`
: 'https://dify.ai/pricing#plans-and-features'
import { Dialog, DialogPortal } from '@langgenius/dify-ui/dialog'
import { useQueryState } from 'nuqs'
import { lazy, Suspense } from 'react'
import { pricingQueryParamName, pricingQueryParser } from './query-params'
const PricingDialogContent = lazy(() =>
import('./dialog-content').then((module) => ({ default: module.PricingDialogContent })),
)
export function Pricing() {
const [pricing, setPricing] = useQueryState(pricingQueryParamName, pricingQueryParser)
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) onCancel()
}}
>
<DialogContent className="inset-0 size-full max-h-none max-w-none translate-0 overflow-hidden rounded-none border-none bg-saas-background p-0 shadow-none">
<DialogClose
render={
<IconButton
variant="secondary"
size="xl"
className="absolute inset-e-5.5 top-6 z-10 rounded-full"
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
>
<span aria-hidden="true" className="i-ri-close-line size-5" />
</IconButton>
}
/>
<ScrollArea className="h-full w-full overflow-hidden">
<ScrollAreaViewport className="overscroll-contain">
<ScrollAreaContent className="min-h-full min-w-300">
<PricingContent pricingPageURL={pricingPageURL} />
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
<ScrollAreaScrollbar orientation="horizontal">
<ScrollAreaThumb />
</ScrollAreaScrollbar>
<ScrollAreaCorner className="bg-saas-background" />
</ScrollArea>
</DialogContent>
</Dialog>
<Suspense fallback={null}>
<Dialog
open={pricing === 'open'}
onOpenChange={(open) => {
setPricing(open ? 'open' : null)
}}
>
<DialogPortal>
<PricingDialogContent />
</DialogPortal>
</Dialog>
</Suspense>
)
}

View File

@ -31,18 +31,19 @@ const ICON_MAP = {
}
type CloudPlanItemProps = {
currentPlan: CloudPlan
plan: CloudPlan
billingInterval: GetBillingSubscriptionData['query']['interval']
isEducationDiscountEligible: boolean
billing:
| {
currentPlan: CloudPlan
isEducationDiscountEligible: boolean | undefined
}
| undefined
}
export function CloudPlanItem({
plan,
currentPlan,
billingInterval,
isEducationDiscountEligible,
}: CloudPlanItemProps) {
export function CloudPlanItem({ plan, billingInterval, billing }: CloudPlanItemProps) {
const currentPlan = billing?.currentPlan
const isEducationDiscountEligible = billing?.isEducationDiscountEligible
const { t } = useTranslation()
const canManageBilling = useAtomValue(isCurrentWorkspaceManagerAtom)
const [isPlanActionPending, setIsPlanActionPending] = React.useState(false)
@ -53,7 +54,11 @@ export function CloudPlanItem({
const planInfo = ALL_PLANS[plan]
const isCurrent = plan === currentPlan
const isCurrentPaidPlan = isCurrent && !isFreePlan
const isPlanDisabled = isCurrentPaidPlan ? false : planInfo.level <= ALL_PLANS[currentPlan].level
const isPlanDisabled =
!billing ||
(!isCurrentPaidPlan &&
(billing.isEducationDiscountEligible === undefined ||
planInfo.level <= ALL_PLANS[billing.currentPlan].level))
const isEducationDiscountSupportedPlan = plan === 'professional' && isYearly
const educationDiscountWarningText =
canManageBilling &&
@ -83,7 +88,7 @@ export function CloudPlanItem({
const runPlanAction = async () => {
if (isPlanActionPending || isEducationDiscountLoading) return
if (isPlanDisabled) return
if (!billing || isPlanDisabled) return
setIsPlanActionPending(true)
try {
@ -197,7 +202,7 @@ export function CloudPlanItem({
variant="tertiary"
size={null}
disabled={isPlanDisabled}
className="h-auto w-full justify-start gap-x-2 rounded-none bg-components-button-tertiary-bg py-3 pr-4 pl-5 system-xl-semibold text-text-primary hover:bg-components-button-tertiary-bg-hover data-disabled:bg-components-button-tertiary-bg-disabled data-disabled:text-text-disabled data-disabled:hover:bg-components-button-tertiary-bg-disabled data-[plan=professional]:bg-saas-dify-blue-static data-[plan=professional]:text-text-primary-on-surface data-[plan=professional]:hover:bg-saas-dify-blue-static-hover data-[plan=team]:bg-saas-background-inverted data-[plan=team]:text-background-default data-[plan=team]:hover:bg-saas-background-inverted-hover"
className="h-auto w-full justify-start gap-x-2 rounded-none bg-components-button-tertiary-bg py-3 pr-4 pl-5 system-xl-semibold text-text-primary hover:bg-components-button-tertiary-bg-hover data-disabled:bg-components-button-tertiary-bg-disabled data-disabled:text-text-disabled data-disabled:hover:bg-components-button-tertiary-bg-disabled data-[plan=professional]:not-data-disabled:bg-saas-dify-blue-static data-[plan=professional]:not-data-disabled:text-text-primary-on-surface data-[plan=professional]:not-data-disabled:hover:bg-saas-dify-blue-static-hover data-[plan=team]:not-data-disabled:bg-saas-background-inverted data-[plan=team]:not-data-disabled:text-background-default data-[plan=team]:not-data-disabled:hover:bg-saas-background-inverted-hover"
onClick={handlePlanButtonClick}
>
<span className="grow text-start">{buttonLabel}</span>

View File

@ -2,7 +2,7 @@ import { PlanFeatureInfotip } from './infotip'
export function CloudPlanFeature({ label, description }: { label: string; description?: string }) {
return (
<div className="flex items-center">
<div className="flex min-h-4.5 items-center">
<span className="grow system-sm-regular text-text-secondary">{label}</span>
{description && <PlanFeatureInfotip label={label} content={description} />}
</div>

View File

@ -0,0 +1,5 @@
import { parseAsStringLiteral } from 'nuqs'
export const pricingQueryParamName = 'pricing'
export const pricingQueryParser = parseAsStringLiteral(['open'])

View File

@ -7,7 +7,6 @@ import UsageInfo from '@/app/components/billing/usage-info'
type Props = Readonly<{
show: boolean
onClose: () => void
onUpgrade: () => void
usage: number
total: number
resetInDays?: number
@ -16,7 +15,6 @@ type Props = Readonly<{
export default function TriggerEventsLimitModal({
show,
onClose,
onUpgrade,
usage,
total,
resetInDays,
@ -27,7 +25,6 @@ export default function TriggerEventsLimitModal({
<PlanUpgradeModal
show={show}
onClose={onClose}
onUpgrade={onUpgrade}
Icon={TriggerAll}
title={t(($) => $['triggerLimitModal.title'], { ns: 'billing' })}
description={t(($) => $['triggerLimitModal.description'], { ns: 'billing' })}

View File

@ -1,12 +1,17 @@
'use client'
import type { CSSProperties, FC } from 'react'
import type { I18nKeysWithPrefix } from '@/types/i18n'
import { Button } from '@langgenius/dify-ui/button'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useQueryState } from 'nuqs'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { SparklesSoft } from '@/app/components/base/icons/src/public/common'
import { useModalContext } from '@/context/modal-context'
import {
pricingQueryParamName,
pricingQueryParser,
} from '@/app/components/billing/pricing/query-params'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { PremiumBadgeButton } from '../../base/premium-badge'
@ -42,13 +47,13 @@ const UpgradeBtn: FC<Props> = ({
...systemFeaturesQueryOptions(),
select: ({ deployment_edition }) => deployment_edition,
})
const { setShowPricingModal } = useModalContext()
const [, setPricing] = useQueryState(pricingQueryParamName, pricingQueryParser)
if (deploymentEdition !== 'CLOUD') return null
const handleClick = () => {
if (_onClick) _onClick()
else setShowPricingModal()
else setPricing('open')
}
const onClick = () => {
handleClick()

View File

@ -1,15 +1,20 @@
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
import type { GetSystemFeaturesResponse } from '@dify/contracts/api/console/system-features/types.gen'
import type { ReactElement } from 'react'
import { screen } from '@testing-library/react'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { contactSalesUrl } from '@/app/components/billing/config'
import { useModalContext } from '@/context/modal-context'
import { consoleQuery } from '@/service/console'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import {
createConsoleQueryClient,
renderWithConsoleQuery as renderWithoutPricing,
} from '@/test/console/query-data'
import CustomPage from '../index'
const onPricingUrlUpdate = vi.hoisted(() => vi.fn())
let deploymentEdition: GetSystemFeaturesResponse['deployment_edition'] = 'COMMUNITY'
let canReplaceLogo = true
let plan: CloudPlan = 'professional'
@ -21,14 +26,14 @@ vi.mock('@/config', async (importOriginal) => {
}
})
function render(ui: ReactElement) {
function renderWithData(ui: ReactElement) {
const queryClient = createConsoleQueryClient()
queryClient.setQueryData(consoleQuery.workspaces.customConfig.get.queryKey(), {
remove_webapp_brand: false,
replace_webapp_logo: null,
})
return renderWithConsoleQuery(ui, {
return renderWithoutPricing(ui, {
queryClient,
features: {
can_replace_logo: canReplaceLogo,
@ -57,27 +62,22 @@ const { mockToast } = vi.hoisted(() => {
return { mockToast }
})
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: mockToast,
}))
const mockUseModalContext = vi.mocked(useModalContext)
function render(...args: Parameters<typeof renderWithData>) {
args[0] = <NuqsTestingAdapter onUrlUpdate={onPricingUrlUpdate}>{args[0]}</NuqsTestingAdapter>
return renderWithData(...args)
}
describe('CustomPage', () => {
const setShowPricingModal = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
deploymentEdition = 'COMMUNITY'
canReplaceLogo = true
plan = 'professional'
mockUseModalContext.mockReturnValue({
setShowPricingModal,
} as unknown as ReturnType<typeof useModalContext>)
})
// Integration coverage for the page and its child custom brand section.
@ -104,7 +104,9 @@ describe('CustomPage', () => {
await user.click(screen.getByRole('button', { name: 'billing.upgradeBtn.encourageShort' }))
expect(setShowPricingModal).toHaveBeenCalledTimes(1)
await waitFor(() =>
expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('pricing')).toBe('open'),
)
})
it('should show the contact link for professional workspaces', () => {

View File

@ -1,7 +1,11 @@
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useQueryState } from 'nuqs'
import { useTranslation } from 'react-i18next'
import { contactSalesUrl } from '@/app/components/billing/config'
import { useModalContext } from '@/context/modal-context'
import {
pricingQueryParamName,
pricingQueryParser,
} from '@/app/components/billing/pricing/query-params'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { consoleQuery } from '@/service/console'
import CustomWebAppBrand from '../custom-web-app-brand'
@ -21,7 +25,7 @@ const CustomPage = () => {
}),
}),
)
const { setShowPricingModal } = useModalContext()
const [, setPricing] = useQueryState(pricingQueryParamName, pricingQueryParser)
const showBillingTip = deploymentEdition === 'CLOUD' && billing?.canReplaceLogo === false
const showContact =
deploymentEdition === 'CLOUD' && (billing?.plan === 'professional' || billing?.plan === 'team')
@ -41,7 +45,7 @@ const CustomPage = () => {
<button
type="button"
className="flex h-10 w-30 cursor-pointer items-center justify-center rounded-3xl border-none bg-white p-0 system-md-semibold text-text-accent shadow-xs hover:opacity-95"
onClick={() => setShowPricingModal()}
onClick={() => setPricing('open')}
>
{t(($) => $['upgradeBtn.encourageShort'], { ns: 'billing' })}
</button>

View File

@ -3,9 +3,11 @@ import type { DataSourceAuth } from '@/app/components/header/account-setting/dat
import type { NotionPage } from '@/models/common'
import type { CrawlOptions, CrawlResultItem, DataSet, FileItem } from '@/models/datasets'
import { fireEvent, screen } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { DataSourceType } from '@/models/datasets'
import { consoleQuery } from '@/service/console'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import { createConsoleQueryClient, createConsoleQueryWrapper } from '@/test/console/query-data'
import { render as renderWithConsoleState } from '@/test/console/render'
import StepOne from '../index'
let mockPlan: {
@ -37,11 +39,18 @@ const render = (ui: React.ReactElement, vectorSpaceUsageUnknown = false) => {
limit: mockPlan.total.vectorSpace,
usage_unknown: vectorSpaceUsageUnknown,
})
return renderWithConsoleQuery(ui, {
const { wrapper: QueryWrapper } = createConsoleQueryWrapper({
systemFeatures: { deployment_edition: deploymentEdition },
queryClient,
features: { billing: { subscription: { plan: mockPlan.type } } },
})
return renderWithConsoleState(ui, {
wrapper: ({ children }) => (
<NuqsTestingAdapter>
<QueryWrapper>{children}</QueryWrapper>
</NuqsTestingAdapter>
),
})
}
// Mock config for website crawl features

View File

@ -1,17 +1,14 @@
import { screen } from '@testing-library/react'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import UpgradeCard from '../upgrade-card'
const mockSetShowPricingModal = vi.fn()
const onPricingUrlUpdate = vi.hoisted(() => vi.fn())
const render = (ui: React.ReactElement) =>
const renderWithoutPricing = (ui: React.ReactElement) =>
renderWithConsoleQuery(ui, { systemFeatures: { deployment_edition: 'CLOUD' } })
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({ setShowPricingModal: mockSetShowPricingModal }),
}))
vi.mock('@/app/components/billing/upgrade-btn', () => ({
default: ({ onClick }: { onClick?: () => void }) => (
<button type="button" onClick={onClick}>
@ -20,6 +17,11 @@ vi.mock('@/app/components/billing/upgrade-btn', () => ({
),
}))
function render(...args: Parameters<typeof renderWithoutPricing>) {
args[0] = <NuqsTestingAdapter onUrlUpdate={onPricingUrlUpdate}>{args[0]}</NuqsTestingAdapter>
return renderWithoutPricing(...args)
}
describe('UpgradeCard', () => {
it('opens pricing from the upgrade action', async () => {
const user = userEvent.setup()
@ -27,6 +29,8 @@ describe('UpgradeCard', () => {
await user.click(screen.getByRole('button', { name: 'upgrade' }))
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
await waitFor(() =>
expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('pricing')).toBe('open'),
)
})
})

View File

@ -1,7 +1,9 @@
import type { NotionPage } from '@/models/common'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { render as renderWithConsoleState } from '@/test/console/render'
import PreviewPanel from '../preview-panel'
vi.mock('../../../file-preview', () => ({
@ -87,3 +89,14 @@ describe('PreviewPanel', () => {
expect(defaultProps.hidePlanUpgradeModal).toHaveBeenCalledOnce()
})
})
function render(ui: React.ReactElement) {
const { wrapper: QueryWrapper } = createConsoleQueryWrapper()
return renderWithConsoleState(ui, {
wrapper: ({ children }) => (
<NuqsTestingAdapter>
<QueryWrapper>{children}</QueryWrapper>
</NuqsTestingAdapter>
),
})
}

View File

@ -1,11 +1,16 @@
'use client'
import type { FC } from 'react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useQueryState } from 'nuqs'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import {
pricingQueryParamName,
pricingQueryParser,
} from '@/app/components/billing/pricing/query-params'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import { useModalContext } from '@/context/modal-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
const UpgradeCard: FC = () => {
@ -14,11 +19,11 @@ const UpgradeCard: FC = () => {
...systemFeaturesQueryOptions(),
select: ({ deployment_edition }) => deployment_edition,
})
const { setShowPricingModal } = useModalContext()
const [, setPricing] = useQueryState(pricingQueryParamName, pricingQueryParser)
const handleUpgrade = useCallback(() => {
setShowPricingModal()
}, [setShowPricingModal])
setPricing('open')
}, [setPricing])
if (deploymentEdition !== 'CLOUD') return null

View File

@ -4,10 +4,12 @@ import type { Mock } from 'vite-plus/test'
import type { DocumentIndexingStatus, IndexingStatusResponse } from '@/models/datasets'
import type { InitialDocumentDetail } from '@/models/pipeline'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import * as React from 'react'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { DatasourceType } from '@/models/pipeline'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { render as renderWithConsoleState } from '@/test/console/render'
import { RETRIEVE_METHOD } from '@/types/app'
import EmbeddingProcess from '../index'
@ -138,10 +140,17 @@ const createDefaultProps = (
})
function render(ui: ReactElement) {
return renderWithConsoleQuery(ui, {
const { wrapper: QueryWrapper } = createConsoleQueryWrapper({
systemFeatures: { deployment_edition: deploymentEdition },
features: { billing: { subscription: { plan: mockPlanType } } },
})
return renderWithConsoleState(ui, {
wrapper: ({ children }) => (
<NuqsTestingAdapter>
<QueryWrapper>{children}</QueryWrapper>
</NuqsTestingAdapter>
),
})
}
describe('EmbeddingProcess', () => {

View File

@ -2,21 +2,18 @@ import type { Datasource } from '@/app/components/rag-pipeline/components/panel/
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import type { Node } from '@/app/components/workflow/types'
import { screen } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { DatasourceType } from '@/models/pipeline'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import StepOneContent from '../step-one-content'
const render = (ui: React.ReactElement) =>
const onPricingUrlUpdate = vi.hoisted(() => vi.fn())
const renderWithoutPricing = (ui: React.ReactElement) =>
renderWithConsoleQuery(ui, { systemFeatures: { deployment_edition: 'CLOUD' } })
// Mock context providers and hooks (底层依赖)
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(() => ({
setShowPricingModal: vi.fn(),
})),
}))
// Mock billing components that have complex provider dependencies
vi.mock('@/app/components/billing/vector-space-full', () => ({
default: () => <div data-testid="vector-space-full">Vector Space Full</div>,
@ -219,6 +216,11 @@ vi.mock('@/service/use-pipeline', () => ({
})),
}))
function render(...args: Parameters<typeof renderWithoutPricing>) {
args[0] = <NuqsTestingAdapter onUrlUpdate={onPricingUrlUpdate}>{args[0]}</NuqsTestingAdapter>
return renderWithoutPricing(...args)
}
describe('StepOneContent', () => {
const mockDatasource: Datasource = {
nodeId: 'test-node-id',

View File

@ -2,8 +2,10 @@ import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
import type { ReactElement } from 'react'
import type { SegmentImportStatus } from '@/types/dataset'
import { fireEvent, screen } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { render as renderWithConsoleState } from '@/test/console/render'
import { segmentImportStatus } from '@/types/dataset'
import { SegmentAdd } from '../index'
@ -12,10 +14,17 @@ let mockPlan: { type: CloudPlan } = { type: 'professional' }
let deploymentEdition: 'CLOUD' | 'COMMUNITY' = 'CLOUD'
function render(ui: ReactElement) {
return renderWithConsoleQuery(ui, {
const { wrapper: QueryWrapper } = createConsoleQueryWrapper({
systemFeatures: { deployment_edition: deploymentEdition },
features: { billing: { subscription: { plan: mockPlan.type } } },
})
return renderWithConsoleState(ui, {
wrapper: ({ children }) => (
<NuqsTestingAdapter>
<QueryWrapper>{children}</QueryWrapper>
</NuqsTestingAdapter>
),
})
}
describe('SegmentAdd', () => {

View File

@ -1,4 +1,3 @@
import type { ModalContextState } from '@/context/modal-context'
import {
DropdownMenu,
DropdownMenuContent,
@ -6,17 +5,15 @@ import {
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useModalContext } from '@/context/modal-context'
import { fireEvent, render as renderWithoutPricing, screen, waitFor } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { getDocDownloadUrl } from '@/service/common'
import { seedFeatures } from '@/test/console/query-data'
import { downloadUrl } from '@/utils/download'
import Compliance from '../compliance'
vi.mock('@/context/modal-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/modal-context')>()
return { ...actual, useModalContext: vi.fn() }
})
const onPricingUrlUpdate = vi.hoisted(() => vi.fn())
vi.mock('@/service/common', () => ({ getDocDownloadUrl: vi.fn() }))
vi.mock('@/service/base', () => ({
request: vi.fn(() => new Promise(() => {})),
@ -30,11 +27,19 @@ vi.mock('@/utils/download', () => ({
const mockSetSettingsDestination = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
return {
...actual,
useQueryState: (...args: Parameters<typeof actual.useQueryState>) =>
args[0] === 'pricing' ? actual.useQueryState(...args) : [null, mockSetSettingsDestination],
}
})
function render(...args: Parameters<typeof renderWithoutPricing>) {
args[0] = <NuqsTestingAdapter onUrlUpdate={onPricingUrlUpdate}>{args[0]}</NuqsTestingAdapter>
return renderWithoutPricing(...args)
}
describe('Compliance', () => {
const mockSetShowPricingModal = vi.fn()
const toastSuccessSpy = vi.spyOn(toast, 'success').mockReturnValue('toast-success')
const toastErrorSpy = vi.spyOn(toast, 'error').mockReturnValue('toast-error')
let queryClient: QueryClient
@ -50,9 +55,6 @@ describe('Compliance', () => {
},
})
seedFeatures(queryClient, { billing: { subscription: { plan: 'sandbox' } } })
vi.mocked(useModalContext).mockReturnValue({
setShowPricingModal: mockSetShowPricingModal,
} as unknown as ModalContextState)
})
const renderWithQueryClient = (ui: React.ReactElement) => {
@ -93,7 +95,7 @@ describe('Compliance', () => {
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('common.compliance.gdpr'))
await waitFor(() => expect(getDocDownloadUrl).toHaveBeenCalledWith('GDPR'))
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
expect(onPricingUrlUpdate).not.toHaveBeenCalled()
})
describe('Rendering', () => {
@ -184,14 +186,16 @@ describe('Compliance', () => {
consoleSpy.mockRestore()
})
it('should handle upgrade click on badge for sandbox plan', () => {
it('should handle upgrade click on badge for sandbox plan', async () => {
// Act
openMenuAndRender()
const upgradeBadges = screen.getAllByText('billing.upgradeBtn.encourageShort')
fireEvent.click(upgradeBadges[0]!)
// Assert
expect(mockSetShowPricingModal).toHaveBeenCalled()
await waitFor(() =>
expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('pricing')).toBe('open'),
)
})
it('should handle upgrade click on badge for non-sandbox plan', () => {

View File

@ -14,11 +14,14 @@ import { useMutation, useQuery } from '@tanstack/react-query'
import { useQueryState } from 'nuqs'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import {
pricingQueryParamName,
pricingQueryParser,
} from '@/app/components/billing/pricing/query-params'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import { useModalContext } from '@/context/modal-context'
import { getDocDownloadUrl } from '@/service/common'
import { consoleQuery } from '@/service/console'
import { downloadUrl } from '@/utils/download'
@ -107,7 +110,7 @@ function ComplianceDocRowItem({ icon, label, docName }: ComplianceDocRowItemProp
select: (data) => data.billing.subscription.plan,
}),
)
const { setShowPricingModal } = useModalContext()
const [, setPricing] = useQueryState(pricingQueryParamName, pricingQueryParser)
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const isFreePlan = plan === 'sandbox'
@ -142,7 +145,7 @@ function ComplianceDocRowItem({ icon, label, docName }: ComplianceDocRowItemProp
return
}
if (isFreePlan) setShowPricingModal()
if (isFreePlan) setPricing('open')
else setSettingsDestination('billing')
}, [
downloadCompliance,
@ -150,7 +153,7 @@ function ComplianceDocRowItem({ icon, label, docName }: ComplianceDocRowItemProp
isFreePlan,
isPending,
setSettingsDestination,
setShowPricingModal,
setPricing,
])
const upgradeTooltip: Record<CloudPlan, string> = {

View File

@ -2,6 +2,7 @@ import type { AccountSettingTab } from '../constants'
import type { ConsoleStateFixture } from '@/test/console/state-fixture'
import { fireEvent, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { useState } from 'react'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { renderWithConsoleQuery } from '@/test/console/query-data'
@ -188,21 +189,26 @@ describe('AccountSetting', () => {
)
}
return renderWithConsoleQuery(<StatefulAccountSetting />, {
features: {
billing: { subscription: { plan: 'sandbox' } },
can_replace_logo: canReplaceLogo,
return renderWithConsoleQuery(
<NuqsTestingAdapter>
<StatefulAccountSetting />
</NuqsTestingAdapter>,
{
features: {
billing: { subscription: { plan: 'sandbox' } },
can_replace_logo: canReplaceLogo,
},
accountProfile: (mockConsoleState.current as ConsoleStateFixture).userProfile,
systemFeatures: {
deployment_edition: deploymentEdition,
webapp_auth: { enabled: true },
branding: { enabled: false },
enable_marketplace: true,
enable_collaboration_mode: false,
rbac_enabled: rbacEnabled,
},
},
accountProfile: (mockConsoleState.current as ConsoleStateFixture).userProfile,
systemFeatures: {
deployment_edition: deploymentEdition,
webapp_auth: { enabled: true },
branding: { enabled: false },
enable_marketplace: true,
enable_collaboration_mode: false,
rbac_enabled: rbacEnabled,
},
})
)
}
beforeEach(() => {

View File

@ -1,7 +1,9 @@
import type { ModelProvider } from '../../../declarations'
import type { CredentialPanelState } from '../../use-credential-panel-state'
import { fireEvent, screen } from '@testing-library/react'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { render as renderWithConsoleState } from '@/test/console/render'
import { CustomConfigurationStatusEnum, PreferredProviderTypeEnum } from '../../../declarations'
import DropdownContent from '../dropdown-content'
@ -575,3 +577,14 @@ describe('DropdownContent', () => {
})
})
})
function render(ui: React.ReactElement) {
const { wrapper: QueryWrapper } = createConsoleQueryWrapper()
return renderWithConsoleState(ui, {
wrapper: ({ children }) => (
<NuqsTestingAdapter>
<QueryWrapper>{children}</QueryWrapper>
</NuqsTestingAdapter>
),
})
}

View File

@ -1,8 +1,12 @@
import { Meter, MeterIndicator, MeterLabel, MeterTrack } from '@langgenius/dify-ui/meter'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useQueryState } from 'nuqs'
import { Trans, useTranslation } from 'react-i18next'
import { CreditsCoin } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
import { useModalContextSelector } from '@/context/modal-context'
import {
pricingQueryParamName,
pricingQueryParser,
} from '@/app/components/billing/pricing/query-params'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { formatNumber } from '@/utils/format'
import { useTrialCredits } from '../use-trial-credits'
@ -23,7 +27,7 @@ export default function CreditsExhaustedAlert({
...systemFeaturesQueryOptions(),
select: ({ deployment_edition }) => deployment_edition,
})
const setShowPricingModal = useModalContextSelector((s) => s.setShowPricingModal)
const [, setPricing] = useQueryState(pricingQueryParamName, pricingQueryParser)
const trialCredits = useTrialCredits()
const credits = creditsOverride ?? trialCredits.credits
const totalCredits = totalCreditsOverride ?? trialCredits.totalCredits
@ -56,7 +60,7 @@ export default function CreditsExhaustedAlert({
<button
type="button"
className="cursor-pointer border-0 bg-transparent p-0 text-left system-xs-medium text-text-accent"
onClick={() => setShowPricingModal()}
onClick={() => setPricing('open')}
/>
) : (
<span />

View File

@ -1,12 +1,17 @@
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
import type { GetWorkflowRunArchivesResponse } from '@dify/contracts/api/console/workflow-run-archives/types.gen'
import { fireEvent, screen } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { useModalContext } from '@/context/modal-context'
import { consoleQuery } from '@/service/console'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import {
createConsoleQueryClient,
renderWithConsoleQuery as renderWithoutPricing,
} from '@/test/console/query-data'
import WorkflowLogArchivesPage from '../index'
const onPricingUrlUpdate = vi.hoisted(() => vi.fn())
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
@ -22,8 +27,6 @@ vi.mock('@/context/modal-context', async (importOriginal) => {
}
})
const mockUseModalContext = vi.mocked(useModalContext)
const archiveData: GetWorkflowRunArchivesResponse = {
summary: {
archived_month_count: 1,
@ -51,22 +54,22 @@ function renderPage() {
const queryClient = createConsoleQueryClient()
queryClient.setQueryData(consoleQuery.workflowRunArchives.get.queryKey(), archiveData)
return renderWithConsoleQuery(<WorkflowLogArchivesPage />, {
return render(<WorkflowLogArchivesPage />, {
queryClient,
systemFeatures: { deployment_edition: 'CLOUD' },
features: { billing: { subscription: { plan } } },
})
}
describe('WorkflowLogArchivesPage', () => {
const setShowPricingModal = vi.fn()
function render(...args: Parameters<typeof renderWithoutPricing>) {
args[0] = <NuqsTestingAdapter onUrlUpdate={onPricingUrlUpdate}>{args[0]}</NuqsTestingAdapter>
return renderWithoutPricing(...args)
}
describe('WorkflowLogArchivesPage', () => {
beforeEach(() => {
vi.clearAllMocks()
plan = 'professional'
mockUseModalContext.mockReturnValue({
setShowPricingModal,
} as unknown as ReturnType<typeof useModalContext>)
})
describe('Plan access', () => {
@ -82,7 +85,7 @@ describe('WorkflowLogArchivesPage', () => {
expect(screen.queryByText('2025-03')).not.toBeInTheDocument()
})
it('should open pricing modal from the sandbox upgrade guidance', () => {
it('should open pricing modal from the sandbox upgrade guidance', async () => {
// Arrange
plan = 'sandbox'
renderPage()
@ -91,7 +94,9 @@ describe('WorkflowLogArchivesPage', () => {
fireEvent.click(screen.getByRole('button', { name: 'billing.upgradeBtn.encourageShort' }))
// Assert
expect(setShowPricingModal).toHaveBeenCalledTimes(1)
await waitFor(() =>
expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('pricing')).toBe('open'),
)
})
it('should show archive content for paid workspaces', () => {

View File

@ -10,11 +10,15 @@ import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { skipToken, useMutation, useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useQueryState } from 'nuqs'
import { useEffect, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import {
pricingQueryParamName,
pricingQueryParser,
} from '@/app/components/billing/pricing/query-params'
import { API_PREFIX } from '@/config'
import { useModalContext } from '@/context/modal-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { consoleQuery } from '@/service/console'
@ -263,7 +267,7 @@ export default function WorkflowLogArchivesPage() {
function ArchivedLogsUpgradeBanner() {
const { t } = useTranslation()
const { setShowPricingModal } = useModalContext()
const [, setPricing] = useQueryState(pricingQueryParamName, pricingQueryParser)
return (
<div className="flex flex-col gap-4 rounded-xl bg-linear-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2 p-4 pl-6 shadow-lg backdrop-blur-xs sm:flex-row sm:items-center sm:justify-between">
@ -278,7 +282,7 @@ function ArchivedLogsUpgradeBanner() {
<button
type="button"
className="flex h-10 w-30 shrink-0 cursor-pointer items-center justify-center rounded-3xl border-none bg-white p-0 system-md-semibold text-text-accent shadow-xs hover:opacity-95"
onClick={() => setShowPricingModal()}
onClick={() => setPricing('open')}
>
{t(($) => $['upgradeBtn.encourageShort'], { ns: 'billing' })}
</button>

View File

@ -11,7 +11,6 @@ import type {
import type { ReactNode } from 'react'
import type { Mock } from 'vite-plus/test'
import type { StepByStepTourSessionState } from '@/app/components/step-by-step-tour/types'
import type { ModalContextState } from '@/context/modal-context'
import type { UserProfileWithMeta } from '@/features/account-profile/client'
import type { ConsoleStateFixture } from '@/test/console/state-fixture'
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
@ -19,6 +18,7 @@ import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createStore, Provider as JotaiProvider } from 'jotai'
import { queryClientAtom } from 'jotai-tanstack-query'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { DETAIL_SIDEBAR_STORAGE_KEY } from '@/app/components/detail-sidebar/storage'
import { LEARN_DIFY_HIDDEN_STORAGE_KEY } from '@/app/components/explore/learn-dify/storage'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
@ -28,15 +28,19 @@ import {
stepByStepTourSkipRecoveryVisibleAtom,
} from '@/app/components/step-by-step-tour/state'
import { STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY } from '@/app/components/step-by-step-tour/storage'
import { useModalContext } from '@/context/modal-context'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { usePathname, useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/console'
import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console/query-data'
import {
createConsoleQueryClient,
renderWithConsoleQuery as renderWithoutPricing,
} from '@/test/console/query-data'
import { seedRegisteredConsoleStateFixture } from '@/test/console/state-fixture'
import { AppModeEnum } from '@/types/app'
import { MainNav } from '../index'
const onPricingUrlUpdate = vi.hoisted(() => vi.fn())
type StepByStepTourTestUiState = StepByStepTourSessionState & { minimized: boolean }
const activeGradientMaskClassName = 'aria-[current=page]:dify-blue-glass-surface'
@ -398,11 +402,15 @@ vi.mock('@/config', async (importOriginal) => {
})
const mockPush = vi.fn()
const mockSetShowPricingModal = vi.fn()
const mockSetSettingsDestination = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
return {
...actual,
useQueryState: (...args: Parameters<typeof actual.useQueryState>) =>
args[0] === 'pricing' ? actual.useQueryState(...args) : [null, mockSetSettingsDestination],
}
})
let mockPathname = '/apps'
let mockInstalledApps: InstalledAppResponse[] = []
@ -523,7 +531,7 @@ const consoleState: MainNavConsoleState = {
const workspaceMenuAccessibleName = /Solar Studio.*common\.mainNav\.workspace\.openMenu/
type MainNavSystemFeatures = Exclude<
NonNullable<Parameters<typeof renderWithConsoleQuery>[1]>['systemFeatures'],
NonNullable<Parameters<typeof renderWithoutPricing>[1]>['systemFeatures'],
null | undefined
>
@ -539,7 +547,7 @@ const renderMainNav = (
options: {
store?: ReturnType<typeof createStore>
extra?: ReactNode
educationStatus?: NonNullable<Parameters<typeof renderWithConsoleQuery>[1]>['educationStatus']
educationStatus?: NonNullable<Parameters<typeof renderWithoutPricing>[1]>['educationStatus']
skipRecoveryVisible?: boolean
} = {},
) => {
@ -585,7 +593,7 @@ const renderMainNav = (
...systemFeatures.branding,
},
}
return renderWithConsoleQuery(
return render(
<JotaiProvider store={store}>
<MainNav />
{options.extra}
@ -604,6 +612,11 @@ const renderMainNav = (
)
}
function render(...args: Parameters<typeof renderWithoutPricing>) {
args[0] = <NuqsTestingAdapter onUrlUpdate={onPricingUrlUpdate}>{args[0]}</NuqsTestingAdapter>
return renderWithoutPricing(...args)
}
describe('MainNav', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -646,9 +659,7 @@ describe('MainNav', () => {
mockConsoleState.current = consoleState
skillEnabled = true
educationEnabled = false
;(useModalContext as Mock).mockReturnValue({
setShowPricingModal: mockSetShowPricingModal,
} as unknown as ModalContextState)
mockInstalledAppsRequest.mockImplementation(
async ({ query }: { query: { cursor?: string; name?: string } }) => {
if (mockInstalledAppsPending) return new Promise(() => {})
@ -1269,7 +1280,9 @@ describe('MainNav', () => {
await waitFor(() => {
expect(screen.queryByText('common.userProfile.discord')).not.toBeInTheDocument()
})
expect(mockSetShowPricingModal).toHaveBeenCalled()
await waitFor(() =>
expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('pricing')).toBe('open'),
)
})
it('hides the help menu when branding is enabled', () => {
@ -1292,7 +1305,9 @@ describe('MainNav', () => {
expect(mockSetSettingsDestination).not.toHaveBeenCalledWith('provider')
fireEvent.click(screen.getByText('billing.upgradeBtn.plain'))
expect(mockSetShowPricingModal).toHaveBeenCalled()
await waitFor(() =>
expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('pricing')).toBe('open'),
)
fireEvent.click(screen.getByRole('button', { name: workspaceMenuAccessibleName }))
fireEvent.click(await screen.findByText('common.mainNav.workspace.settings'))
@ -1324,7 +1339,7 @@ describe('MainNav', () => {
expect(screen.queryByText('billing.upgradeBtn.plain')).not.toBeInTheDocument()
})
it('shows the view plan shortcut for paid workspaces', () => {
it('shows the view plan shortcut for paid workspaces', async () => {
mockConsoleState.current = {
...consoleState,
currentWorkspace: {
@ -1337,7 +1352,9 @@ describe('MainNav', () => {
expect(screen.queryByText('billing.upgradeBtn.encourageShort')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('billing.upgradeBtn.plain'))
expect(mockSetShowPricingModal).toHaveBeenCalled()
await waitFor(() =>
expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('pricing')).toBe('open'),
)
expect(mockSetSettingsDestination).not.toHaveBeenCalledWith(ACCOUNT_SETTING_TAB.BILLING)
})

View File

@ -6,32 +6,27 @@ import {
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { zendeskRuntime } from '@/app/components/base/zendesk/runtime'
import { mailToSupport } from '@/app/components/header/utils/util'
import { useModalContext } from '@/context/modal-context'
import { consoleQuery } from '@/service/console'
import { createConsoleQueryClient, createConsoleQueryWrapper } from '@/test/console/query-data'
import { render } from '@/test/console/render'
import { render as renderWithoutPricing } from '@/test/console/render'
import SupportMenu from '../support-menu'
let plan: CloudPlan = 'team'
const {
mockConfig,
mockOpenZendeskWindow,
mockMailToSupport,
mockSetShowPricingModal,
mockToastError,
} = vi.hoisted(() => ({
mockConfig: {
supportEmailAddress: '',
zendeskWidgetKey: 'zendesk-key',
},
mockOpenZendeskWindow: vi.fn(),
mockMailToSupport: vi.fn(),
mockSetShowPricingModal: vi.fn(),
mockToastError: vi.fn(),
}))
const { mockConfig, mockOpenZendeskWindow, mockMailToSupport, onPricingUrlUpdate, mockToastError } =
vi.hoisted(() => ({
mockConfig: {
supportEmailAddress: '',
zendeskWidgetKey: 'zendesk-key',
},
mockOpenZendeskWindow: vi.fn(),
mockMailToSupport: vi.fn(),
onPricingUrlUpdate: vi.fn(),
mockToastError: vi.fn(),
}))
const mockConsoleState = vi.hoisted(() => ({
current: {
langGeniusVersionInfo: { current_version: '1.0.0' },
@ -67,9 +62,10 @@ vi.mock('@/config', async (importOriginal) => {
}
})
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
}))
function render(...args: Parameters<typeof renderWithoutPricing>) {
args[0] = <NuqsTestingAdapter onUrlUpdate={onPricingUrlUpdate}>{args[0]}</NuqsTestingAdapter>
return renderWithoutPricing(...args)
}
describe('SupportMenu', () => {
let deploymentEdition: 'COMMUNITY' | 'ENTERPRISE' | 'CLOUD' = 'CLOUD'
@ -85,9 +81,7 @@ describe('SupportMenu', () => {
userProfile: { email: 'user@example.com' },
}
plan = 'team'
;(useModalContext as Mock).mockReturnValue({
setShowPricingModal: mockSetShowPricingModal,
})
;(mailToSupport as Mock).mockReturnValue('mailto:support@example.com')
})
@ -150,7 +144,7 @@ describe('SupportMenu', () => {
await waitFor(() => expect(mockToastError).toHaveBeenCalledWith('common.api.actionFailed'))
})
it('renders contact us with upgrade badge for Cloud sandbox plan without dedicated support', () => {
it('renders contact us with upgrade badge for Cloud sandbox plan without dedicated support', async () => {
plan = 'sandbox'
renderSupportMenu()
@ -165,13 +159,16 @@ describe('SupportMenu', () => {
screen.queryByRole('button', { name: 'billing.upgradeBtn.encourageShort' }),
).not.toBeInTheDocument()
fireEvent.click(
screen.getByRole('menuitem', {
name: 'common.userProfile.contactUs billing.upgradeBtn.encourageShort',
}),
)
const upgradeItem = screen.getByRole('menuitem', {
name: 'common.userProfile.contactUs billing.upgradeBtn.encourageShort',
})
expect(upgradeItem).not.toHaveAttribute('aria-label')
expect(screen.getByText('billing.upgradeBtn.encourageShort')).not.toHaveAttribute('aria-hidden')
fireEvent.click(upgradeItem)
expect(mockSetShowPricingModal).toHaveBeenCalled()
await waitFor(() =>
expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('pricing')).toBe('open'),
)
expect(zendeskRuntime.open).not.toHaveBeenCalled()
})
@ -186,7 +183,7 @@ describe('SupportMenu', () => {
fireEvent.click(screen.getByRole('menuitem', { name: 'common.userProfile.contactUs' }))
expect(zendeskRuntime.open).toHaveBeenCalledWith('CLOUD')
expect(mockSetShowPricingModal).not.toHaveBeenCalled()
expect(onPricingUrlUpdate).not.toHaveBeenCalled()
})
it('keeps email support for Cloud sandbox plan with support email and no Zendesk configured', () => {

View File

@ -2,20 +2,21 @@ import type {
GetWorkspacesCurrentSummaryResponse,
TenantListItemResponse,
} from '@dify/contracts/api/console/workspaces/types.gen'
import type { ModalContextState } from '@/context/modal-context'
import { zLicenseStatus } from '@dify/contracts/api/console/system-features/zod.gen'
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context'
import { consoleQuery } from '@/service/console'
import {
createConsoleQueryClient,
renderWithConsoleQuery,
renderWithConsoleQuery as renderWithoutPricing,
seedSystemFeaturesLicense,
} from '@/test/console/query-data'
import { WorkspaceCard } from '../workspace-card'
const onPricingUrlUpdate = vi.hoisted(() => vi.fn())
const {
mockFetchWorkspaces,
mockSwitchWorkspace,
@ -38,10 +39,6 @@ vi.mock('@/context/permission-state', async () => {
return createPermissionStateModuleMock(() => mockConsoleState.current)
})
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
}))
vi.mock('@/service/console', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/console')>()
const consoleQuery = new Proxy(actual.consoleQuery, {
@ -100,11 +97,14 @@ const workspaceMenuAccessibleName = new RegExp(
`${currentWorkspaceValue.name}.*common\\.mainNav\\.workspace\\.openMenu`,
)
const mockSetShowPricingModal = vi.fn()
const mockSetSettingsDestination = vi.fn()
vi.mock('nuqs', async (importOriginal) => {
const actual = await importOriginal<typeof import('nuqs')>()
return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] }
return {
...actual,
useQueryState: (...args: Parameters<typeof actual.useQueryState>) =>
args[0] === 'pricing' ? actual.useQueryState(...args) : [null, mockSetSettingsDestination],
}
})
let mockCurrentWorkspace: GetWorkspacesCurrentSummaryResponse | undefined = currentWorkspaceValue
let mockWorkspaces: TenantListItemResponse[] = []
@ -116,7 +116,7 @@ const mockCurrentWorkspaceQuery = (
mockCurrentWorkspace = isPending ? undefined : data
}
type RenderWorkspaceCardOptions = Parameters<typeof renderWithConsoleQuery>[1] & {
type RenderWorkspaceCardOptions = Parameters<typeof renderWithoutPricing>[1] & {
seedWorkspaces?: boolean
systemFeaturesLicense?: Parameters<typeof seedSystemFeaturesLicense>[1]
}
@ -133,7 +133,7 @@ const renderWorkspaceCard = (options?: RenderWorkspaceCardOptions) => {
queryClient.setQueryData(consoleQuery.workspaces.get.queryKey(), { workspaces: mockWorkspaces })
if (systemFeaturesLicense) seedSystemFeaturesLicense(queryClient, systemFeaturesLicense)
return renderWithConsoleQuery(<WorkspaceCard />, {
return render(<WorkspaceCard />, {
...renderOptions,
queryClient,
currentWorkspace: mockCurrentWorkspace ? undefined : null,
@ -146,6 +146,11 @@ const mockWorkspacePermissionKeys = (workspacePermissionKeys: string[]) => {
}
}
function render(...args: Parameters<typeof renderWithoutPricing>) {
args[0] = <NuqsTestingAdapter onUrlUpdate={onPricingUrlUpdate}>{args[0]}</NuqsTestingAdapter>
return renderWithoutPricing(...args)
}
describe('WorkspaceCard', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -171,9 +176,6 @@ describe('WorkspaceCard', () => {
mockSwitchWorkspace.mockReturnValue(new Promise(() => {}))
mockCurrentWorkspaceQuery()
mockWorkspacePermissionKeys(['workspace.member.manage'])
vi.mocked(useModalContext).mockReturnValue({
setShowPricingModal: mockSetShowPricingModal,
} as unknown as ModalContextState)
})
it('includes the visible workspace name in the menu trigger accessible name', () => {

View File

@ -1,15 +1,19 @@
import { DropdownMenuItem, DropdownMenuLinkItem } from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useQueryState } from 'nuqs'
import { useTranslation } from 'react-i18next'
import { zendeskRuntime } from '@/app/components/base/zendesk/runtime'
import {
pricingQueryParamName,
pricingQueryParser,
} from '@/app/components/billing/pricing/query-params'
import {
ExternalLinkIndicator,
MenuItemContent,
} from '@/app/components/header/account-dropdown/menu-item-content'
import { generateMailToLink, mailToSupport } from '@/app/components/header/utils/util'
import { SUPPORT_EMAIL_ADDRESS, ZENDESK_WIDGET_KEY } from '@/config'
import { useModalContext } from '@/context/modal-context'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { consoleQuery } from '@/service/console'
@ -33,7 +37,7 @@ export default function SupportMenu() {
currentVersion: data.meta.currentVersion,
}),
})
const { setShowPricingModal } = useModalContext()
const [, setPricing] = useQueryState(pricingQueryParamName, pricingQueryParser)
const hasDedicatedChannel =
(deploymentEdition === 'CLOUD' && (plan === 'professional' || plan === 'team')) ||
Boolean(SUPPORT_EMAIL_ADDRESS.trim())
@ -56,10 +60,9 @@ export default function SupportMenu() {
<>
{shouldShowUpgradeContact && (
<DropdownMenuItem
aria-label={`${t(($) => $['userProfile.contactUs'], { ns: 'common' })} ${t(($) => $['upgradeBtn.encourageShort'], { ns: 'billing' })}`}
className="mx-0 h-8 gap-1 px-3 py-1"
onClick={() => {
setShowPricingModal()
setPricing('open')
}}
>
<MenuItemContent
@ -70,10 +73,7 @@ export default function SupportMenu() {
</span>
}
trailing={
<span
aria-hidden
className="max-w-30 shrink-0 truncate px-1 system-xs-semibold-uppercase text-saas-dify-blue-accessible"
>
<span className="max-w-30 shrink-0 truncate px-1 system-xs-semibold-uppercase text-saas-dify-blue-accessible">
{t(($) => $['upgradeBtn.encourageShort'], { ns: 'billing' })}
</span>
}

View File

@ -17,13 +17,16 @@ import { useQueryState } from 'nuqs'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { WorkspaceAvatar } from '@/app/components/base/workspace-avatar'
import {
pricingQueryParamName,
pricingQueryParser,
} from '@/app/components/billing/pricing/query-params'
import {
settingsQueryParamName,
settingsQueryParser,
} from '@/app/components/header/account-setting/query-params'
import LicenseBadge from '@/app/components/header/license-badge'
import { buildIntegrationPath } from '@/app/components/integrations/routes'
import { useModalContext } from '@/context/modal-context'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import Link from '@/next/link'
@ -271,7 +274,7 @@ export function WorkspaceCard() {
const currentWorkspace = currentWorkspaceQuery.data
const workspaces = workspacesQuery.data?.workspaces
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const { setShowPricingModal } = useModalContext()
const [, setPricing] = useQueryState(pricingQueryParamName, pricingQueryParser)
const [, setSettingsDestination] = useQueryState(settingsQueryParamName, settingsQueryParser)
const isCloudEdition = deploymentEdition === 'CLOUD'
const prefetchWorkspaces = () => {
@ -325,7 +328,7 @@ export function WorkspaceCard() {
planActionLabel={planActionLabel}
creditsHref={buildIntegrationPath('provider')}
onPrefetchWorkspaces={prefetchWorkspaces}
onPlanClick={setShowPricingModal}
onPlanClick={() => setPricing('open')}
/>
<PopoverContent
placement="bottom-start"

View File

@ -1,14 +1,17 @@
import type { IconInfo } from '@/models/datasets'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import * as React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { seedAccountProfileQuery } from '@/test/console/account-profile'
import { seedFeatures, seedSystemFeatures } from '@/test/console/query-data'
import { render } from '@/test/console/render'
import { render as renderWithoutPricing } from '@/test/console/render'
import Publisher from '../index'
import { Popup } from '../popup'
const onPricingUrlUpdate = vi.hoisted(() => vi.fn())
vi.mock('@/features/system-features/state', async () => {
const { atom } = await import('jotai')
return {
@ -119,13 +122,6 @@ vi.mock('@/context/permission-state', async () => {
}))
})
const mockSetShowPricingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContextSelector: <T,>(
selector: (state: { setShowPricingModal: typeof mockSetShowPricingModal }) => T,
): T => selector({ setShowPricingModal: mockSetShowPricingModal }),
}))
let publishEnabled = true
const toastMocks = vi.hoisted(() => ({
@ -246,6 +242,11 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>)
}
function render(...args: Parameters<typeof renderWithoutPricing>) {
args[0] = <NuqsTestingAdapter onUrlUpdate={onPricingUrlUpdate}>{args[0]}</NuqsTestingAdapter>
return renderWithoutPricing(...args)
}
describe('publisher', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -363,7 +364,9 @@ describe('publisher', () => {
await waitFor(() => {
expect(screen.queryByText('pipeline.common.publishAs')).not.toBeInTheDocument()
})
expect(mockSetShowPricingModal).toHaveBeenCalled()
await waitFor(() =>
expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('pricing')).toBe('open'),
)
})
})
})
@ -497,7 +500,9 @@ describe('publisher', () => {
.find((btn) => btn.textContent?.includes('pipeline.common.publishAs'))
fireEvent.click(publishAsButton!)
expect(mockSetShowPricingModal).toHaveBeenCalled()
await waitFor(() =>
expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('pricing')).toBe('open'),
)
})
it('should show publish as knowledge pipeline modal when permitted', async () => {

View File

@ -1,12 +1,15 @@
import { fireEvent, screen } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { render as renderWithConsoleState } from '@/test/console/render'
import { Popup } from '../popup'
const onPricingUrlUpdate = vi.hoisted(() => vi.fn())
let mockIsAllowPublishAsCustom = true
const render = (ui: React.ReactElement) => {
const renderWithoutPricing = (ui: React.ReactElement) => {
const { wrapper } = createConsoleQueryWrapper({
systemFeatures: { deployment_edition: 'CLOUD' },
features: { knowledge_pipeline: { publish_enabled: mockIsAllowPublishAsCustom } },
@ -45,7 +48,7 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
const mockHandleCheckBeforePublish = vi.fn().mockResolvedValue(true)
const mockSetPublishedAt = vi.fn()
const mockMutateDatasetRes = vi.fn()
const mockSetShowPricingModal = vi.fn()
const mockInvalidPublishedPipelineInfo = vi.fn()
const mockInvalidDatasetList = vi.fn()
const mockInvalidCustomizedTemplateList = vi.fn()
@ -150,12 +153,6 @@ vi.mock('@/context/i18n', () => ({
useDocLink: () => () => 'https://docs.dify.ai',
}))
vi.mock('@/context/modal-context', () => ({
useModalContextSelector: <T,>(
selector: (state: { setShowPricingModal: typeof mockSetShowPricingModal }) => T,
) => selector({ setShowPricingModal: mockSetShowPricingModal }),
}))
vi.mock('@/hooks/use-api-access-url', () => ({
useDatasetApiAccessUrl: () => '/api/datasets/ds-123',
}))
@ -221,6 +218,11 @@ vi.mock('@remixicon/react', () => ({
RiTerminalBoxLine: () => <span />,
}))
function render(...args: Parameters<typeof renderWithoutPricing>) {
args[0] = <NuqsTestingAdapter onUrlUpdate={onPricingUrlUpdate}>{args[0]}</NuqsTestingAdapter>
return renderWithoutPricing(...args)
}
describe('Popup', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -339,7 +341,7 @@ describe('Popup', () => {
})
describe('Publish As Knowledge Pipeline', () => {
it('should show pricing modal when not allowed', () => {
it('should show pricing modal when not allowed', async () => {
mockIsAllowPublishAsCustom = false
const onRequestClose = vi.fn()
render(<Popup onRequestClose={onRequestClose} />)
@ -347,7 +349,9 @@ describe('Popup', () => {
fireEvent.click(screen.getByText('pipeline.common.publishAs'))
expect(onRequestClose).toHaveBeenCalledTimes(1)
expect(mockSetShowPricingModal).toHaveBeenCalled()
await waitFor(() =>
expect(onPricingUrlUpdate.mock.lastCall?.[0].searchParams.get('pricing')).toBe('open'),
)
})
it('should request closing the outer popover before opening publish-as modal', () => {

View File

@ -17,16 +17,20 @@ import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useBoolean } from 'ahooks'
import { useAtomValue } from 'jotai'
import { useQueryState } from 'nuqs'
import { useCallback, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import Divider from '@/app/components/base/divider'
import { SparklesSoft } from '@/app/components/base/icons/src/public/common'
import PremiumBadge from '@/app/components/base/premium-badge'
import {
pricingQueryParamName,
pricingQueryParser,
} from '@/app/components/billing/pricing/query-params'
import { useChecklistBeforePublish } from '@/app/components/workflow/hooks/use-checklist'
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useModalContextSelector } from '@/context/modal-context'
import {
workspacePermissionKeysAtom,
workspacePermissionKeysLoadingAtom,
@ -89,7 +93,7 @@ export function Popup({
select: (features) => features.knowledge_pipeline.publish_enabled,
}),
)
const setShowPricingModal = useModalContextSelector((s) => s.setShowPricingModal)
const [, setPricing] = useQueryState(pricingQueryParamName, pricingQueryParser)
const apiReferenceUrl = useDatasetApiAccessUrl()
const canAddDocumentsToDataset = getDatasetACLCapabilities(dataset?.permission_keys, {
currentUserId,
@ -201,7 +205,7 @@ export function Popup({
onRequestClose?.()
if (!isAllowPublishAsCustomKnowledgePipelineTemplate) {
if (deploymentEdition === 'CLOUD') setShowPricingModal()
if (deploymentEdition === 'CLOUD') setPricing('open')
} else {
onShowPublishAsKnowledgePipelineModal?.()
}
@ -210,7 +214,7 @@ export function Popup({
deploymentEdition,
onRequestClose,
onShowPublishAsKnowledgePipelineModal,
setShowPricingModal,
setPricing,
])
return (
<div

View File

@ -904,6 +904,23 @@ describe('StepByStepTourMount', () => {
expect(document.body.querySelector('[data-base-ui-portal]')).not.toBeInTheDocument()
})
it('hides expanded tour overlays while pricing is open', async () => {
setStepByStepTourTestState({
manuallyEnabledWorkspaceIds: ['workspace-1'],
manuallyDisabledWorkspaceIds: [],
minimized: false,
completedTaskIds: [],
skipped: false,
})
renderStepByStepTourMount('?pricing=open')
await waitFor(() => {
expect(screen.queryByRole('region', { name: 'Get to know Dify' })).not.toBeInTheDocument()
})
expect(document.body.querySelector('[data-base-ui-portal]')).not.toBeInTheDocument()
})
it('hides expanded tour overlays while the Education expiration notice is open', async () => {
setStepByStepTourTestState({
manuallyEnabledWorkspaceIds: ['workspace-1'],

View File

@ -25,6 +25,10 @@ import { useAtomValue, useSetAtom } from 'jotai'
import { useQueryState } from 'nuqs'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
pricingQueryParamName,
pricingQueryParser,
} from '@/app/components/billing/pricing/query-params'
import {
settingsQueryParamName,
settingsQueryParser,
@ -143,6 +147,7 @@ export default function StepByStepTourMount({
className,
recoveryAnchorRef,
}: StepByStepTourMountProps) {
const [pricing] = useQueryState(pricingQueryParamName, pricingQueryParser)
const router = useRouter()
const pathname = usePathname()
const docLink = useDocLink()
@ -260,6 +265,7 @@ export default function StepByStepTourMount({
const overlayVisible =
visible &&
!hasBlockingModalOpen &&
pricing !== 'open' &&
!settingsDestination &&
!(pathname === '/apps' && educationExpireNotice)
const completionPromptVisible = visible && allTasksCompleted && !activeTask

View File

@ -1,11 +1,20 @@
import type { ModalContextState } from '@/context/modal-context'
import { toast } from '@langgenius/dify-ui/toast'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import {
act,
fireEvent,
render as renderWithoutPricing,
screen,
waitFor,
} from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { AuthHeaderPrefix, AuthType } from '@/app/components/tools/types'
import { parseParamsSchema } from '@/service/tools'
import EditCustomCollectionModal from '../index'
const onPricingUrlUpdate = vi.hoisted(() => vi.fn())
vi.mock('ahooks', async () => {
const actual = await vi.importActual<typeof import('ahooks')>('ahooks')
return {
@ -19,13 +28,11 @@ vi.mock('@/service/tools', () => ({
}))
const parseParamsSchemaMock = vi.mocked(parseParamsSchema)
const mockSetShowPricingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContext: (): ModalContextState => ({
hasBlockingModalOpen: false,
setShowModerationSettingModal: vi.fn(),
setShowExternalDataToolModal: vi.fn(),
setShowPricingModal: mockSetShowPricingModal,
setShowAnnotationFullModal: vi.fn(),
setShowModelModal: vi.fn(),
setShowExternalKnowledgeAPIModal: vi.fn(),
@ -42,6 +49,11 @@ vi.mock('@/context/i18n', async () => {
}
})
function render(...args: Parameters<typeof renderWithoutPricing>) {
args[0] = <NuqsTestingAdapter onUrlUpdate={onPricingUrlUpdate}>{args[0]}</NuqsTestingAdapter>
return renderWithoutPricing(...args)
}
describe('EditCustomCollectionModal', () => {
const mockOnHide = vi.fn()
const mockOnAdd = vi.fn()

View File

@ -1,6 +1,7 @@
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
import type { VersionHistory } from '@/types/workflow'
import { fireEvent, screen } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import {
createConsoleQueryClient,
createConsoleQueryWrapper,
@ -171,5 +172,5 @@ function renderWorkflowComponent(
createConsoleQueryWrapper({ queryClient })
seedSystemFeatures(queryClient, { deployment_edition: deploymentEdition })
seedFeatures(queryClient, { billing: { subscription: { plan: mockPlanType } } })
return renderWorkflow(ui, { ...options, queryClient })
return renderWorkflow(<NuqsTestingAdapter>{ui}</NuqsTestingAdapter>, { ...options, queryClient })
}

View File

@ -1,5 +1,7 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { render as renderWithConsoleState } from '@/test/console/render'
import { withSelectorKey } from '@/test/i18n-mock'
import { DeliveryMethodType } from '../../../types'
import DeliveryMethodForm from '../index'
@ -134,3 +136,14 @@ describe('DeliveryMethodForm', () => {
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
})
})
function render(ui: React.ReactElement) {
const { wrapper: QueryWrapper } = createConsoleQueryWrapper()
return renderWithConsoleState(ui, {
wrapper: ({ children }) => (
<NuqsTestingAdapter>
<QueryWrapper>{children}</QueryWrapper>
</NuqsTestingAdapter>
),
})
}

View File

@ -1,49 +1,28 @@
import { fireEvent, screen } from '@testing-library/react'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { UpgradeModal } from '../upgrade-modal'
const render = (ui: React.ReactElement) =>
renderWithConsoleQuery(ui, { systemFeatures: { deployment_edition: 'CLOUD' } })
const mockUseModalContextSelector = vi.hoisted(() => vi.fn())
vi.mock('@/context/modal-context', () => ({
useModalContextSelector: (selector: (state: { setShowPricingModal: () => void }) => () => void) =>
mockUseModalContextSelector(selector),
}))
describe('human-input/delivery-method/upgrade-modal', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render upgrade copy and handle hide and upgrade actions', () => {
const handleClose = vi.fn()
const handleShowPricingModal = vi.fn()
mockUseModalContextSelector.mockImplementation((selector) =>
selector({
setShowPricingModal: handleShowPricingModal,
}),
)
render(<UpgradeModal open onOpenChange={handleClose} />)
expect(
screen.getByText('workflow.nodes.humanInput.deliveryMethod.upgradeTip'),
).toBeInTheDocument()
expect(
screen.getByText('workflow.nodes.humanInput.deliveryMethod.upgradeTipContent'),
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', {
name: 'workflow.nodes.humanInput.deliveryMethod.upgradeTipHide',
}),
)
expect(handleClose).toHaveBeenCalledWith(false)
fireEvent.click(screen.getByRole('button', { name: /billing.upgradeBtn.encourageShort/i }))
expect(handleShowPricingModal).toHaveBeenCalledTimes(1)
})
it('renders upgrade copy and handles hide and pricing actions', async () => {
const user = userEvent.setup()
const onUrlUpdate = vi.fn()
const onOpenChange = vi.fn()
renderWithConsoleQuery(
<NuqsTestingAdapter onUrlUpdate={onUrlUpdate}>
<UpgradeModal open onOpenChange={onOpenChange} />
</NuqsTestingAdapter>,
{ systemFeatures: { deployment_edition: 'CLOUD' } },
)
expect(screen.getByRole('dialog')).toHaveTextContent(
'workflow.nodes.humanInput.deliveryMethod.upgradeTipContent',
)
await user.click(
screen.getByRole('button', { name: 'workflow.nodes.humanInput.deliveryMethod.upgradeTipHide' }),
)
expect(onOpenChange).toHaveBeenCalledWith(false)
await user.click(screen.getByRole('button', { name: /billing.upgradeBtn.encourageShort/i }))
await waitFor(() =>
expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('pricing')).toBe('open'),
)
})

View File

@ -1,11 +1,15 @@
import { Button } from '@langgenius/dify-ui/button'
import { RiMailSendFill } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useQueryState } from 'nuqs'
import { useTranslation } from 'react-i18next'
import { SparklesSoft } from '@/app/components/base/icons/src/public/common'
import { PremiumBadgeButton } from '@/app/components/base/premium-badge'
import { UpgradeModal as BaseUpgradeModal } from '@/app/components/base/upgrade-modal'
import { useModalContextSelector } from '@/context/modal-context'
import {
pricingQueryParamName,
pricingQueryParser,
} from '@/app/components/billing/pricing/query-params'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
type UpgradeModalProps = {
@ -19,9 +23,9 @@ export function UpgradeModal({ open, onOpenChange }: UpgradeModalProps) {
...systemFeaturesQueryOptions(),
select: ({ deployment_edition }) => deployment_edition,
})
const setShowPricingModal = useModalContextSelector((s) => s.setShowPricingModal)
const [, setPricing] = useQueryState(pricingQueryParamName, pricingQueryParser)
const handleUpgrade = () => {
setShowPricingModal()
setPricing('open')
}
return (

View File

@ -4,8 +4,10 @@ import type { Shape } from '../../../store'
import type { VersionHistory } from '@/types/workflow'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { useEffect, useRef } from 'react'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { render as renderWithConsoleState } from '@/test/console/render'
import { AppModeEnum } from '@/types/app'
import { VersionHistoryContextMenuOptions, WorkflowVersion } from '../../../types'
@ -530,8 +532,15 @@ describe('VersionHistoryPanel', () => {
})
function render(ui: ReactElement) {
return renderWithConsoleQuery(ui, {
const { wrapper: QueryWrapper } = createConsoleQueryWrapper({
systemFeatures: { deployment_edition: deploymentEdition },
features: { billing: { subscription: { plan: mockPlanType } } },
})
return renderWithConsoleState(ui, {
wrapper: ({ children }) => (
<NuqsTestingAdapter>
<QueryWrapper>{children}</QueryWrapper>
</NuqsTestingAdapter>
),
})
}

View File

@ -1,6 +1,7 @@
import type { CloudPlan } from '@dify/contracts/api/console/features/types.gen'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import {
createConsoleQueryClient,
seedFeatures,
@ -17,7 +18,7 @@ const renderActionMenu = (ui: React.ReactElement) => {
const queryClient = createConsoleQueryClient()
seedFeatures(queryClient, { billing: { subscription: { plan: mockPlanType } } })
seedSystemFeatures(queryClient, { deployment_edition: deploymentEdition })
return renderWorkflowComponent(ui, { queryClient })
return renderWorkflowComponent(<NuqsTestingAdapter>{ui}</NuqsTestingAdapter>, { queryClient })
}
vi.mock('@/config', async (importOriginal) => {

View File

@ -1,5 +1,6 @@
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { render } from '@/test/console/render'
import { EducationExpireNotice } from '../index'
@ -11,10 +12,6 @@ const mockEducationStatus = vi.hoisted(() => ({
}))
const mockPricingModal = vi.hoisted(() => ({ isOpen: false }))
vi.mock('@/hooks/use-query-params', () => ({
usePricingModal: () => [mockPricingModal.isOpen, vi.fn()],
}))
vi.mock('@/next/dynamic', () => ({
default:
() =>
@ -38,7 +35,12 @@ const renderNotice = (accountId = 'user-1') => {
},
})
return render(<EducationExpireNotice />, { wrapper })
return render(
<NuqsTestingAdapter searchParams={mockPricingModal.isOpen ? '?pricing=open' : ''}>
<EducationExpireNotice />
</NuqsTestingAdapter>,
{ wrapper },
)
}
describe('EducationExpireNotice', () => {

View File

@ -1,4 +1,5 @@
import { screen } from '@testing-library/react'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { render } from '@/test/console/render'
import ExpireNoticeModal from '../modal'
@ -7,10 +8,6 @@ vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => path,
}))
vi.mock('@/context/modal-context', () => ({
useModalContextSelector: () => vi.fn(),
}))
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({ formatTime: () => '2026/08/20' }),
}))
@ -21,9 +18,14 @@ describe('ExpireNoticeModal', () => {
systemFeatures: { deployment_edition: 'CLOUD' },
})
render(<ExpireNoticeModal expireAt={1787155200} expired={false} onClose={vi.fn()} />, {
wrapper,
})
render(
<NuqsTestingAdapter>
<ExpireNoticeModal expireAt={1787155200} expired={false} onClose={vi.fn()} />
</NuqsTestingAdapter>,
{
wrapper,
},
)
expect(screen.getByRole('link', { name: 'education.notice.action.reVerify' })).toHaveAttribute(
'href',

View File

@ -1,16 +1,20 @@
'use client'
import { usePricingModal } from '@/hooks/use-query-params'
import { useQueryState } from 'nuqs'
import {
pricingQueryParamName,
pricingQueryParser,
} from '@/app/components/billing/pricing/query-params'
import dynamic from '@/next/dynamic'
import { useEducationExpireNotice } from './use-expire-notice'
const ExpireNoticeModal = dynamic(() => import('./modal'), { ssr: false })
export function EducationExpireNotice() {
const [isPricingModalOpen] = usePricingModal()
const [pricing] = useQueryState(pricingQueryParamName, pricingQueryParser)
const [notice, dismissNotice] = useEducationExpireNotice()
if (!notice || isPricingModalOpen) return null
if (!notice || pricing === 'open') return null
return (
<ExpireNoticeModal

View File

@ -1,12 +1,17 @@
'use client'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { Dialog, DialogClose, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useQueryState } from 'nuqs'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import {
pricingQueryParamName,
pricingQueryParser,
} from '@/app/components/billing/pricing/query-params'
import { useDocLink } from '@/context/i18n'
import { useModalContextSelector } from '@/context/modal-context'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import useTimestamp from '@/hooks/use-timestamp'
import Link from '@/next/link'
@ -30,7 +35,7 @@ const ExpireNoticeModal: React.FC<Props> = ({ expireAt, expired, onClose }) => {
const docLink = useDocLink()
const eduDocLink = docLink('/use-dify/workspace/subscription-management#dify-for-education')
const { formatTime } = useTimestamp()
const setShowPricingModal = useModalContextSelector((s) => s.setShowPricingModal)
const [, setPricing] = useQueryState(pricingQueryParamName, pricingQueryParser)
return (
<Dialog
@ -109,7 +114,7 @@ const ExpireNoticeModal: React.FC<Props> = ({ expireAt, expired, onClose }) => {
<Button
onClick={() => {
onClose()
setShowPricingModal()
setPricing('open')
}}
className="flex items-center"
>

View File

@ -31,7 +31,6 @@ vi.mock('@/i18n-config/language', () => ({
return map[locale] || 'en'
}),
getLanguage: vi.fn(),
getPricingPageLanguage: vi.fn(),
}))
describe('useDocLink', () => {

View File

@ -5,7 +5,7 @@ import { useAtomValue } from 'jotai'
import { useCallback } from 'react'
import { useTranslation } from '#i18n'
import { deploymentEditionAtom } from '@/features/system-features/state'
import { getDocLanguage, getLanguage, getPricingPageLanguage } from '@/i18n-config/language'
import { getDocLanguage, getLanguage } from '@/i18n-config/language'
import { docPathProductAvailability, isProductlessDocPath } from '@/types/doc-paths'
export const useLocale = () => {
@ -18,11 +18,6 @@ export const useGetLanguage = () => {
return getLanguage(locale)
}
export const useGetPricingPageLanguage = () => {
const locale = useLocale()
return getPricingPageLanguage(locale)
}
export const defaultDocBaseUrl = 'https://docs.dify.ai'
export const enterpriseDocBaseUrl = 'https://enterprise-docs.dify.ai'

View File

@ -10,7 +10,6 @@ import type { ExternalDataTool } from '@/models/common'
import type { ModerationConfig, PromptVariable } from '@/models/debug'
import { useCallback, useState } from 'react'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { usePricingModal } from '@/hooks/use-query-params'
import dynamic from '@/next/dynamic'
import { useTriggerEventsLimitModal } from './hooks/use-trigger-events-limit-modal'
import { ModalContext } from './modal-context'
@ -28,12 +27,6 @@ const ExternalDataToolModal = dynamic(
ssr: false,
},
)
const Pricing = dynamic(
() => import('@/app/components/billing/pricing').then((module) => module.Pricing),
{
ssr: false,
},
)
const AnnotationFullModal = dynamic(
() => import('@/app/components/billing/annotation-full/modal'),
{
@ -73,7 +66,6 @@ type ModalContextProviderProps = {
children: ReactNode
}
export const ModalContextProvider = ({ children }: ModalContextProviderProps) => {
const [showPricingModal, setPricingModalOpen] = usePricingModal()
const [showModerationSettingModal, setShowModerationSettingModal] =
useState<ModalState<ModerationConfig> | null>(null)
const [showExternalDataToolModal, setShowExternalDataToolModal] =
@ -178,17 +170,9 @@ export const ModalContextProvider = ({ children }: ModalContextProviderProps) =>
setShowOpeningModal(null)
}
const handleShowPricingModal = useCallback(() => {
setPricingModalOpen(true)
}, [setPricingModalOpen])
const handleCancelPricingModal = useCallback(() => {
setPricingModalOpen(false)
}, [setPricingModalOpen])
const hasBlockingModalOpen = Boolean(
showModerationSettingModal ||
showExternalDataToolModal ||
showPricingModal ||
showAnnotationFullModal ||
showModelModal ||
showExternalKnowledgeAPIModal ||
@ -203,7 +187,6 @@ export const ModalContextProvider = ({ children }: ModalContextProviderProps) =>
hasBlockingModalOpen,
setShowModerationSettingModal,
setShowExternalDataToolModal,
setShowPricingModal: handleShowPricingModal,
setShowAnnotationFullModal: () => setShowAnnotationFullModal(true),
setShowModelModal,
setShowExternalKnowledgeAPIModal,
@ -229,8 +212,6 @@ export const ModalContextProvider = ({ children }: ModalContextProviderProps) =>
/>
)}
{!!showPricingModal && <Pricing onCancel={handleCancelPricingModal} />}
{showAnnotationFullModal && (
<AnnotationFullModal
show={showAnnotationFullModal}
@ -301,10 +282,6 @@ export const ModalContextProvider = ({ children }: ModalContextProviderProps) =>
total={triggerEventsLimitModal.total}
resetInDays={triggerEventsLimitModal.resetInDays}
onClose={dismissTriggerEventsLimitModal}
onUpgrade={() => {
dismissTriggerEventsLimitModal()
handleShowPricingModal()
}}
/>
)}
</>

View File

@ -17,10 +17,6 @@ vi.mock('@/next/navigation', () => ({
useSearchParams: vi.fn(() => new URLSearchParams()),
}))
vi.mock('@/app/components/billing/pricing', () => ({
Pricing: () => <div>billing.plansCommon.mostPopular</div>,
}))
vi.mock('@/app/components/plugins/update-plugin', () => ({
default: ({ onSave }: { onSave: () => void | Promise<void> }) => (
<button data-testid="save-plugin-update" onClick={onSave}>
@ -88,7 +84,7 @@ const renderProvider = (
systemFeatures: { deployment_edition: edition },
})
seedFeatures(queryClient, features)
const { wrapper: NuqsWrapper } = createNuqsTestWrapper()
const { wrapper: NuqsWrapper, onUrlUpdate } = createNuqsTestWrapper()
const wrapper = ({ children: wrapperChildren }: { children: React.ReactNode }) => (
<QueryWrapper>
<NuqsWrapper>{wrapperChildren}</NuqsWrapper>
@ -97,6 +93,7 @@ const renderProvider = (
return {
queryClient,
onUrlUpdate,
...render(<ModalContextProvider>{children}</ModalContextProvider>, { wrapper }),
}
}
@ -246,17 +243,16 @@ describe('ModalContextProvider trigger events limit modal', () => {
}
const user = userEvent.setup()
renderProvider(undefined, features)
const { onUrlUpdate } = renderProvider(undefined, features)
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
await user.click(screen.getByText('billing.triggerLimitModal.upgrade'))
await waitFor(() =>
expect(screen.getByText('billing.plansCommon.mostPopular')).toBeInTheDocument(),
)
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get('pricing')).toBe('open')
expect(screen.queryByText('400')).not.toBeInTheDocument()
expect(screen.getByText('blocked')).toBeInTheDocument()
expect(screen.getByText('clear')).toBeInTheDocument()
})
})

View File

@ -43,7 +43,6 @@ export type ModalContextState = {
hasBlockingModalOpen: boolean
setShowModerationSettingModal: Dispatch<SetStateAction<ModalState<ModerationConfig> | null>>
setShowExternalDataToolModal: Dispatch<SetStateAction<ModalState<ExternalDataTool> | null>>
setShowPricingModal: () => void
setShowAnnotationFullModal: () => void
setShowModelModal: Dispatch<SetStateAction<ModalState<ModelModalType> | null>>
setShowExternalKnowledgeAPIModal: Dispatch<
@ -65,7 +64,6 @@ export const ModalContext = createContext<ModalContextState>({
hasBlockingModalOpen: false,
setShowModerationSettingModal: noop,
setShowExternalDataToolModal: noop,
setShowPricingModal: noop,
setShowAnnotationFullModal: noop,
setShowModelModal: noop,
setShowExternalKnowledgeAPIModal: noop,

View File

@ -8,6 +8,7 @@ import { toast } from '@langgenius/dify-ui/toast'
import { QueryClient } from '@tanstack/react-query'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
import { seedAccountProfileQuery } from '@/test/console/account-profile'
import { createQueryClientWrapper } from '@/test/console/query-client'
import { seedFeatures, seedSystemFeatures } from '@/test/console/query-data'
@ -268,13 +269,24 @@ function createAgentApiAccessResponse(
}
}
function createAccessCardWrapper(queryClient: QueryClient) {
const QueryWrapper = createQueryClientWrapper(queryClient)
return function AccessCardWrapper({ children }: { children: React.ReactNode }) {
return (
<NuqsTestingAdapter>
<QueryWrapper>{children}</QueryWrapper>
</NuqsTestingAdapter>
)
}
}
function renderWithQueryClient(
ui: React.ReactElement,
{ webAppAuthEnabled = true }: { webAppAuthEnabled?: boolean } = {},
) {
const queryClient = createConsoleQueryClient(webAppAuthEnabled)
render(ui, { wrapper: createQueryClientWrapper(queryClient) })
render(ui, { wrapper: createAccessCardWrapper(queryClient) })
return queryClient
}
@ -743,7 +755,7 @@ describe('Agent access surface cards', () => {
const queryClient = createConsoleQueryClient()
const { rerender } = render(
<WebAppAccessCard agent={agentWithoutApp} agentId="agent-1" isLoading={false} />,
{ wrapper: createQueryClientWrapper(queryClient) },
{ wrapper: createAccessCardWrapper(queryClient) },
)
expect(

View File

@ -1,11 +1,6 @@
import { act, waitFor } from '@testing-library/react'
import { renderHookWithNuqs } from '@/test/nuqs-testing'
import {
PRICING_MODAL_QUERY_PARAM,
PRICING_MODAL_QUERY_VALUE,
usePluginInstallation,
usePricingModal,
} from './use-query-params'
import { usePluginInstallation } from './use-query-params'
const renderWithAdapter = <T,>(hook: () => T, searchParams = '') => {
return renderHookWithNuqs(hook, { searchParams })
@ -17,129 +12,6 @@ describe('useQueryParams hooks', () => {
vi.clearAllMocks()
})
// Pricing modal query behavior.
describe('usePricingModal', () => {
it('should return closed state when query param is missing', () => {
// Arrange
const { result } = renderWithAdapter(() => usePricingModal())
// Act
const [isOpen] = result.current
// Assert
expect(isOpen).toBe(false)
})
it('should return open state when query param matches open value', () => {
// Arrange
const { result } = renderWithAdapter(
() => usePricingModal(),
`?${PRICING_MODAL_QUERY_PARAM}=${PRICING_MODAL_QUERY_VALUE}`,
)
// Act
const [isOpen] = result.current
// Assert
expect(isOpen).toBe(true)
})
it('should return closed state when query param has unexpected value', () => {
// Arrange
const { result } = renderWithAdapter(
() => usePricingModal(),
`?${PRICING_MODAL_QUERY_PARAM}=closed`,
)
// Act
const [isOpen] = result.current
// Assert
expect(isOpen).toBe(false)
})
it('should set pricing param when opening', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(() => usePricingModal())
// Act
act(() => {
result.current[1](true)
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
expect(update.searchParams.get(PRICING_MODAL_QUERY_PARAM)).toBe(PRICING_MODAL_QUERY_VALUE)
})
it('should use push history when opening', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(() => usePricingModal())
// Act
act(() => {
result.current[1](true)
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
expect(update.options.history).toBe('push')
})
it('should clear pricing param when closing', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(
() => usePricingModal(),
`?${PRICING_MODAL_QUERY_PARAM}=${PRICING_MODAL_QUERY_VALUE}`,
)
// Act
act(() => {
result.current[1](false)
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
expect(update.searchParams.has(PRICING_MODAL_QUERY_PARAM)).toBe(false)
})
it('should use push history when closing', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(
() => usePricingModal(),
`?${PRICING_MODAL_QUERY_PARAM}=${PRICING_MODAL_QUERY_VALUE}`,
)
// Act
act(() => {
result.current[1](false)
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
expect(update.options.history).toBe('push')
})
it('should respect explicit history options when provided', async () => {
// Arrange
const { result, onUrlUpdate } = renderWithAdapter(() => usePricingModal())
// Act
act(() => {
result.current[1](true, { history: 'replace' })
})
// Assert
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled())
const update = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1]![0]
expect(update.options.history).toBe('replace')
})
})
// Plugin installation query behavior.
describe('usePluginInstallation', () => {
it('should parse package ids from JSON arrays', () => {

View File

@ -13,33 +13,7 @@
* - Use shallow routing to avoid unnecessary re-renders
*/
import { createParser, useQueryState, useQueryStates } from 'nuqs'
/**
* Modal State Query Parameters
* Manages modal visibility and configuration via URL
*/
export const PRICING_MODAL_QUERY_PARAM = 'pricing'
export const PRICING_MODAL_QUERY_VALUE = 'open'
const parseAsPricingModal = createParser<boolean>({
parse: (value) => (value === PRICING_MODAL_QUERY_VALUE ? true : null),
serialize: (value) => (value ? PRICING_MODAL_QUERY_VALUE : ''),
})
.withDefault(false)
.withOptions({ history: 'push' })
/**
* Hook to manage pricing modal state via URL
* @returns [isOpen, setIsOpen] - Tuple like useState
*
* @example
* const [isOpen, setIsOpen] = usePricingModal()
* setIsOpen(true) // Sets ?pricing=open
* setIsOpen(false) // Removes ?pricing
*/
export function usePricingModal() {
return useQueryState(PRICING_MODAL_QUERY_PARAM, parseAsPricingModal)
}
import { createParser, useQueryStates } from 'nuqs'
/**
* Plugin Installation Query Parameters

View File

@ -67,14 +67,6 @@ export const getDocLanguage = (locale: string): DocLanguage => {
return DOC_LANGUAGE[locale] || 'en'
}
const PRICING_PAGE_LANGUAGE: Record<string, string> = {
'ja-JP': 'jp',
}
export const getPricingPageLanguage = (locale: string) => {
return PRICING_PAGE_LANGUAGE[locale] || ''
}
export const getAccessControlTemplateLanguage = (locale: string): AccessControlTemplateLanguage => {
return ACCESS_CONTROL_TEMPLATE_LANGUAGE[locale] || 'en'
}