mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 19:15:59 +08:00
refactor(web): isolate pricing modal state and lifecycle (#41960)
This commit is contained in:
parent
a233b2f53a
commit
12d23062d4
@ -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'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -88,10 +88,9 @@ const renderCloudPlanItem = ({
|
||||
<>
|
||||
<ToastHost timeout={0} />
|
||||
<CloudPlanItem
|
||||
currentPlan={currentPlan}
|
||||
plan={plan}
|
||||
billingInterval={billingInterval}
|
||||
isEducationDiscountEligible={isEducationDiscountEligible}
|
||||
billing={{ currentPlan, isEducationDiscountEligible }}
|
||||
/>
|
||||
</>,
|
||||
{ wrapper },
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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' }))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 })
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
225
web/app/components/billing/pricing/__tests__/content.spec.tsx
Normal file
225
web/app/components/billing/pricing/__tests__/content.spec.tsx
Normal 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()
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
21
web/app/components/billing/pricing/__tests__/footer.spec.tsx
Normal file
21
web/app/components/billing/pricing/__tests__/footer.spec.tsx
Normal 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`)
|
||||
})
|
||||
@ -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">
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 />
|
||||
|
||||
53
web/app/components/billing/pricing/dialog-content.tsx
Normal file
53
web/app/components/billing/pricing/dialog-content.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
5
web/app/components/billing/pricing/query-params.ts
Normal file
5
web/app/components/billing/pricing/query-params.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { parseAsStringLiteral } from 'nuqs'
|
||||
|
||||
export const pricingQueryParamName = 'pricing'
|
||||
|
||||
export const pricingQueryParser = parseAsStringLiteral(['open'])
|
||||
@ -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' })}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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> = {
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
@ -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>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
)
|
||||
})
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -31,7 +31,6 @@ vi.mock('@/i18n-config/language', () => ({
|
||||
return map[locale] || 'en'
|
||||
}),
|
||||
getLanguage: vi.fn(),
|
||||
getPricingPageLanguage: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('useDocLink', () => {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user