From 12d23062d432a62dddb07e639118a16ade5101b8 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Tue, 8 Sep 2026 07:53:39 +0000 Subject: [PATCH] refactor(web): isolate pricing modal state and lifecycle (#41960) --- .../billing/billing-integration.test.tsx | 124 ++++------ .../billing/cloud-plan-payment-flow.test.tsx | 3 +- .../education-verification-flow.test.tsx | 16 +- .../billing/pricing-modal-flow.test.tsx | 125 +++++++--- web/app/(commonLayout)/global-mounts.tsx | 2 + .../app-publisher/__tests__/sections.spec.tsx | 15 +- .../retention-upgrade-notice.spec.tsx | 28 +-- .../settings/__tests__/index.spec.tsx | 58 ++--- .../app/overview/settings/index.tsx | 13 +- .../__tests__/index.spec.tsx | 33 ++- .../base/features/new-feature-panel/index.tsx | 8 +- .../billing/plan-upgrade-modal/index.tsx | 16 +- .../pricing/__tests__/content.spec.tsx | 225 ++++++++++++++++++ .../billing/pricing/__tests__/dialog.spec.tsx | 117 ++++++--- .../billing/pricing/__tests__/footer.spec.tsx | 21 ++ .../billing/pricing/assets/cloud.tsx | 18 +- .../billing/pricing/assets/self-hosted.tsx | 16 +- .../components/billing/pricing/content.tsx | 143 ++++++----- .../billing/pricing/dialog-content.tsx | 53 +++++ web/app/components/billing/pricing/footer.tsx | 27 ++- web/app/components/billing/pricing/index.tsx | 78 ++---- .../pricing/plans/cloud-plan-item/index.tsx | 27 ++- .../plans/cloud-plan-item/list/item/index.tsx | 2 +- .../billing/pricing/query-params.ts | 5 + .../trigger-events-limit-modal/index.tsx | 3 - .../components/billing/upgrade-btn/index.tsx | 11 +- .../custom-page/__tests__/index.spec.tsx | 32 +-- .../components/custom/custom-page/index.tsx | 10 +- .../create/step-one/__tests__/index.spec.tsx | 13 +- .../step-one/__tests__/upgrade-card.spec.tsx | 20 +- .../__tests__/preview-panel.spec.tsx | 15 +- .../datasets/create/step-one/upgrade-card.tsx | 13 +- .../__tests__/index.spec.tsx | 13 +- .../steps/__tests__/step-one-content.spec.tsx | 16 +- .../segment-add/__tests__/index.spec.tsx | 13 +- .../__tests__/compliance.spec.tsx | 34 +-- .../header/account-dropdown/compliance.tsx | 11 +- .../account-setting/__tests__/index.spec.tsx | 34 +-- .../__tests__/dropdown-content.spec.tsx | 15 +- .../credits-exhausted-alert.tsx | 10 +- .../__tests__/index.spec.tsx | 31 ++- .../workflow-log-archives-page/index.tsx | 10 +- .../main-nav/__tests__/index.spec.tsx | 47 ++-- .../__tests__/support-menu.spec.tsx | 61 +++-- .../__tests__/workspace-card.spec.tsx | 30 +-- .../main-nav/components/support-menu.tsx | 16 +- .../main-nav/components/workspace-card.tsx | 9 +- .../publisher/__tests__/index.spec.tsx | 25 +- .../publisher/__tests__/popup.spec.tsx | 26 +- .../rag-pipeline-header/publisher/popup.tsx | 12 +- .../__tests__/mount.spec.tsx | 17 ++ .../components/step-by-step-tour/mount.tsx | 6 + .../__tests__/index.spec.tsx | 18 +- .../__tests__/header-in-restoring.spec.tsx | 3 +- .../delivery-method/__tests__/index.spec.tsx | 15 +- .../__tests__/upgrade-modal.spec.tsx | 69 ++---- .../delivery-method/upgrade-modal.tsx | 10 +- .../__tests__/index.spec.tsx | 13 +- .../action-menu/__tests__/index.spec.tsx | 3 +- .../expire-notice/__tests__/index.spec.tsx | 12 +- .../expire-notice/__tests__/modal.spec.tsx | 16 +- web/app/education/expire-notice/index.tsx | 10 +- web/app/education/expire-notice/modal.tsx | 11 +- web/context/i18n.spec.ts | 1 - web/context/i18n.ts | 7 +- web/context/modal-context-provider.tsx | 23 -- web/context/modal-context.test.tsx | 16 +- web/context/modal-context.ts | 2 - .../__tests__/access-surface-cards.spec.tsx | 16 +- web/hooks/use-query-params.spec.tsx | 130 +--------- web/hooks/use-query-params.ts | 28 +-- web/i18n-config/language.ts | 8 - 72 files changed, 1268 insertions(+), 869 deletions(-) create mode 100644 web/app/components/billing/pricing/__tests__/content.spec.tsx create mode 100644 web/app/components/billing/pricing/__tests__/footer.spec.tsx create mode 100644 web/app/components/billing/pricing/dialog-content.tsx create mode 100644 web/app/components/billing/pricing/query-params.ts diff --git a/web/__tests__/billing/billing-integration.test.tsx b/web/__tests__/billing/billing-integration.test.tsx index b3d28cf9c83..cb41580ac00 100644 --- a/web/__tests__/billing/billing-integration.test.tsx +++ b/web/__tests__/billing/billing-integration.test.tsx @@ -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 = {} let mockVectorSpace: GetFeaturesVectorSpaceResponse = { size: 0, limit: 50, usage_unknown: false } let mockConsoleState: Record = {} 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 = {}) => { // 1. Billing Page + Plan Component Integration // Tests the full data flow: BillingPage → PlanComp → UsageInfo → ProgressBar // ═══════════════════════════════════════════════════════════════════════════ +function render(...args: Parameters) { + args[0] = {args[0]} + 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() @@ -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() @@ -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( - , + 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', () => { { 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( - , - ) + render() 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( - , - ) + render() - // 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'), + ) }) }) diff --git a/web/__tests__/billing/cloud-plan-payment-flow.test.tsx b/web/__tests__/billing/cloud-plan-payment-flow.test.tsx index eb8ea8da748..6b2971465a2 100644 --- a/web/__tests__/billing/cloud-plan-payment-flow.test.tsx +++ b/web/__tests__/billing/cloud-plan-payment-flow.test.tsx @@ -88,10 +88,9 @@ const renderCloudPlanItem = ({ <> , { wrapper }, diff --git a/web/__tests__/billing/education-verification-flow.test.tsx b/web/__tests__/billing/education-verification-flow.test.tsx index 23a57eb55e1..84217786e38 100644 --- a/web/__tests__/billing/education-verification-flow.test.tsx +++ b/web/__tests__/billing/education-verification-flow.test.tsx @@ -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 = {} let mockVectorSpace: GetFeaturesVectorSpaceResponse = { size: 0, limit: 50, usage_unknown: false } let mockConsoleState: Record = {} 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) { + args[0] = {args[0]} + return renderWithoutPricing(...args) +} + describe('Education Verification Flow', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/web/__tests__/billing/pricing-modal-flow.test.tsx b/web/__tests__/billing/pricing-modal-flow.test.tsx index 29c7d3db54b..40ffa9370a4 100644 --- a/web/__tests__/billing/pricing-modal-flow.test.tsx +++ b/web/__tests__/billing/pricing-modal-flow.test.tsx @@ -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 +} // ─── Mock state ────────────────────────────────────────────────────────────── let mockConsoleState: Record = {} @@ -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({ui}, { 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( + <> + + + , + ) + 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() + it('should render header with close button and footer with pricing link', async () => { + await render() // 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() + it('should default to cloud category with three cloud plans', async () => { + await render() 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() + it('should show plan range switcher (annual billing toggle) by default for cloud', async () => { + await render() 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() + it('should show the tax exclusion notice in the footer for cloud category', async () => { + await render() 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() + 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() + await render() 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() + await render() 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() + await render() 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() + await render() // 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() + it('should show monthly prices by default', async () => { + await render() // 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() + it('should show "Free" for sandbox plan regardless of range', async () => { + await render() expect(screen.getByText(/plansCommon\.free/i)).toBeInTheDocument() }) - it('should show "most popular" badge only for professional plan', () => { - render() + it('should show "most popular" badge only for professional plan', async () => { + await render() 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() + await render() 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() + await render() 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() + await render() 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() + await render() 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() + await render() // 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() + await render() 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() + it('should render pricing link with correct URL', async () => { + await render() 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', ) }) }) diff --git a/web/app/(commonLayout)/global-mounts.tsx b/web/app/(commonLayout)/global-mounts.tsx index f85cfff5a5c..d07d707a572 100644 --- a/web/app/(commonLayout)/global-mounts.tsx +++ b/web/app/(commonLayout)/global-mounts.tsx @@ -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() { + ) } diff --git a/web/app/components/app/app-publisher/__tests__/sections.spec.tsx b/web/app/components/app/app-publisher/__tests__/sections.spec.tsx index f2dd587ddb6..5b970b4d3ac 100644 --- a/web/app/components/app/app-publisher/__tests__/sections.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/sections.spec.tsx @@ -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 }) => ( + + {children} + + ), + }) +} diff --git a/web/app/components/app/log/__tests__/retention-upgrade-notice.spec.tsx b/web/app/components/app/log/__tests__/retention-upgrade-notice.spec.tsx index 52027ad8e20..438db42ad0b 100644 --- a/web/app/components/app/log/__tests__/retention-upgrade-notice.spec.tsx +++ b/web/app/components/app/log/__tests__/retention-upgrade-notice.spec.tsx @@ -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() - return { - ...actual, - useModalContext: vi.fn(), - } -}) +const onPricingUrlUpdate = vi.hoisted(() => vi.fn()) -const mockUseModalContext = vi.mocked(useModalContext) +function render(...args: Parameters) { + args[0] = {args[0]} + 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) }) 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([ diff --git a/web/app/components/app/overview/settings/__tests__/index.spec.tsx b/web/app/components/app/overview/settings/__tests__/index.spec.tsx index 760b47c7d59..71683d3d37f 100644 --- a/web/app/components/app/overview/settings/__tests__/index.spec.tsx +++ b/web/app/components/app/overview/settings/__tests__/index.spec.tsx @@ -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('@/context/i18n') @@ -122,12 +110,21 @@ const renderSettingsModal = (appInfo = mockAppInfo, canDeploy = false) => const inputPlaceholderName = 'appOverview.overview.appInfo.settings.more.inputPlaceholder' +function render(...args: Parameters) { + const wrap = (ui: ReactElement) => ( + {ui} + ) + 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[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( - , - { queryClient, systemFeatures: { deployment_edition: 'CLOUD' } }, - ) + render(, { + 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' })) diff --git a/web/app/components/app/overview/settings/index.tsx b/web/app/components/app/overview/settings/index.tsx index f112427a6ba..4f25d6fd20a 100644 --- a/web/app/components/app/overview/settings/index.tsx +++ b/web/app/components/app/overview/settings/index.tsx @@ -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 = ({ 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 = ({ if (nextLanguage) setLanguage(nextLanguage.value) } const handlePlanClick = useCallback(() => { - setShowPricingModal() - }, [setShowPricingModal]) + setPricing('open') + }, [setPricing]) const shouldResetForm = isShow && (!previousIsShow || settingsResetKey !== previousSettingsResetKey) diff --git a/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx b/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx index d5db454180f..0d4f2584767 100644 --- a/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx +++ b/web/app/components/base/features/new-feature-panel/__tests__/index.spec.tsx @@ -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( - - - , + + + + + , ) } @@ -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 }) diff --git a/web/app/components/base/features/new-feature-panel/index.tsx b/web/app/components/base/features/new-feature-panel/index.tsx index 1f4194d3918..01ba967f4e9 100644 --- a/web/app/components/base/features/new-feature-panel/index.tsx +++ b/web/app/components/base/features/new-feature-panel/index.tsx @@ -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 ( 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 ( 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( + + + , + { 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((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((_, 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) => 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() +}) diff --git a/web/app/components/billing/pricing/__tests__/dialog.spec.tsx b/web/app/components/billing/pricing/__tests__/dialog.spec.tsx index 7902c88ba26..42b41de60c5 100644 --- a/web/app/components/billing/pricing/__tests__/dialog.spec.tsx +++ b/web/app/components/billing/pricing/__tests__/dialog.spec.tsx @@ -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 = {} +const dialogModule = vi.hoisted(() => ({ ready: Promise.resolve() })) -vi.mock('../content', () => ({ - PricingContent: () =>
pricing-content
, -})) - -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: () => ( + <> +
+

Plans loaded

+ + ), })) -describe('Pricing dialog lifecycle', () => { - beforeEach(() => { - vi.clearAllMocks() - mockConsoleState = { - isCurrentWorkspaceManager: true, - } - ;(useGetPricingPageLanguage as Mock).mockReturnValue('en') +function PricingEntry() { + const [, setPricing] = useQueryState(pricingQueryParamName, pricingQueryParser) + return +} + +function CancelPricing() { + const [, setPricing] = useQueryState(pricingQueryParamName, pricingQueryParser) + return +} + +describe('Pricing URL dialog', () => { + it.each(['', '?pricing=closed'])('stays closed for %s', (searchParams) => { + renderWithNuqs(, { 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(, { wrapper }) + let resolveModule!: () => void + dialogModule.ready = new Promise((resolve) => { + resolveModule = resolve + }) + const { onUrlUpdate } = renderWithNuqs( + <> + + + + , + ) + 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( + <> + + + , + { 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(, { 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) }) }) diff --git a/web/app/components/billing/pricing/__tests__/footer.spec.tsx b/web/app/components/billing/pricing/__tests__/footer.spec.tsx new file mode 100644 index 00000000000..e92f5fdf7a6 --- /dev/null +++ b/web/app/components/billing/pricing/__tests__/footer.spec.tsx @@ -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() + const link = screen.getByRole('link', { name: 'billing.plansCommon.comparePlanAndFeatures' }) + expect(link).toHaveAttribute('href', `https://dify.ai${prefix}/pricing/dify-cloud#compare`) + rerender() + expect(link).toHaveAttribute('href', `https://dify.ai${prefix}/pricing/dify-enterprise#compare`) +}) diff --git a/web/app/components/billing/pricing/assets/cloud.tsx b/web/app/components/billing/pricing/assets/cloud.tsx index 3a043e53fcb..a2e6a193997 100644 --- a/web/app/components/billing/pricing/assets/cloud.tsx +++ b/web/app/components/billing/pricing/assets/cloud.tsx @@ -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 ( - + { rx="2" fill="var(--color-text-quaternary)" /> - + { rx="2" fill="var(--color-text-quaternary)" /> - + { rx="2" fill="var(--color-text-quaternary)" /> - + { rx="2" fill="var(--color-text-quaternary)" /> - + diff --git a/web/app/components/billing/pricing/assets/self-hosted.tsx b/web/app/components/billing/pricing/assets/self-hosted.tsx index c331d1f5a19..862e8c9b599 100644 --- a/web/app/components/billing/pricing/assets/self-hosted.tsx +++ b/web/app/components/billing/pricing/assets/self-hosted.tsx @@ -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 ( @@ -16,7 +10,7 @@ const SelfHosted = ({ isActive }: SelfHostedProps) => { rx="2" fill="var(--color-text-quaternary)" /> - + { rx="2" fill="var(--color-text-quaternary)" /> - + { rx="2" fill="var(--color-text-quaternary)" /> - + { rx="2" fill="var(--color-text-quaternary)" /> - + 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() 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 (
@@ -55,60 +67,40 @@ export function PricingContent({ pricingPageURL }: { pricingPageURL: string }) {
$['plansCommon.title.plans'], { ns: 'billing' })} className="items-center gap-0" > ( - - )} - /> + 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" + > + + {t(($) => $['plansCommon.cloud'], { ns: 'billing' })} + ( - - )} - /> + 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" + > + + {t(($) => $['plansCommon.self'], { ns: 'billing' })} + {isCloud && ( -
- $['plansCommon.yearlyBilling'], { ns: 'billing' })} - size="lg" - checked={billingInterval === 'year'} - onCheckedChange={(checked) => - setSelectedBillingInterval(checked ? 'year' : 'month') - } - /> - - {t(($) => $['plansCommon.annualBilling'], { ns: 'billing', percent: 17 })} - -
+ + + + setSelectedBillingInterval(checked ? 'year' : 'month') + } + /> + + {t(($) => $['plansCommon.annualBilling'], { ns: 'billing', percent: 17 })} + + + )}
@@ -116,28 +108,35 @@ export function PricingContent({ pricingPageURL }: { pricingPageURL: string }) {
- + {pricingError ? ( +
+

{t(($) => $.error, { ns: 'common' })}

+ +
+ ) : ( + !isCheckoutReady && ( + + {t(($) => $.loading, { ns: 'appApi' })} + + ) + )} + - + - +
- +
diff --git a/web/app/components/billing/pricing/dialog-content.tsx b/web/app/components/billing/pricing/dialog-content.tsx new file mode 100644 index 00000000000..262266ac01c --- /dev/null +++ b/web/app/components/billing/pricing/dialog-content.tsx @@ -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 ( + <> + + + $['operation.close'], { ns: 'common' })} + > + + + ) +} diff --git a/web/app/components/billing/pricing/footer.tsx b/web/app/components/billing/pricing/footer.tsx index 4d7464724ec..b26fd5d271d 100644 --- a/web/app/components/billing/pricing/footer.tsx +++ b/web/app/components/billing/pricing/footer.tsx @@ -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> = { + '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 (
{category === 'cloud' && ( -
+
{t(($) => $['plansCommon.taxTip'], { ns: 'billing' })}
)} - + diff --git a/web/app/components/billing/pricing/index.tsx b/web/app/components/billing/pricing/index.tsx index 4975bf3c55b..3518df04106 100644 --- a/web/app/components/billing/pricing/index.tsx +++ b/web/app/components/billing/pricing/index.tsx @@ -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 ( - { - if (!open) onCancel() - }} - > - - $['operation.close'], { ns: 'common' })} - > - - + + { + setPricing(open ? 'open' : null) + }} + > + + + + + ) } diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx index c225cf4b1e7..b0590c7ce5b 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx @@ -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} > {buttonLabel} diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/index.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/index.tsx index 113bc874ce2..9e1b09065b9 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/index.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/list/item/index.tsx @@ -2,7 +2,7 @@ import { PlanFeatureInfotip } from './infotip' export function CloudPlanFeature({ label, description }: { label: string; description?: string }) { return ( -
+
{label} {description && }
diff --git a/web/app/components/billing/pricing/query-params.ts b/web/app/components/billing/pricing/query-params.ts new file mode 100644 index 00000000000..cf7f63133dc --- /dev/null +++ b/web/app/components/billing/pricing/query-params.ts @@ -0,0 +1,5 @@ +import { parseAsStringLiteral } from 'nuqs' + +export const pricingQueryParamName = 'pricing' + +export const pricingQueryParser = parseAsStringLiteral(['open']) diff --git a/web/app/components/billing/trigger-events-limit-modal/index.tsx b/web/app/components/billing/trigger-events-limit-modal/index.tsx index f2ea27b9a61..ec08ad8f28c 100644 --- a/web/app/components/billing/trigger-events-limit-modal/index.tsx +++ b/web/app/components/billing/trigger-events-limit-modal/index.tsx @@ -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({ $['triggerLimitModal.title'], { ns: 'billing' })} description={t(($) => $['triggerLimitModal.description'], { ns: 'billing' })} diff --git a/web/app/components/billing/upgrade-btn/index.tsx b/web/app/components/billing/upgrade-btn/index.tsx index bd2d8e06bb7..3a1cf1e61cd 100644 --- a/web/app/components/billing/upgrade-btn/index.tsx +++ b/web/app/components/billing/upgrade-btn/index.tsx @@ -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 = ({ ...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() diff --git a/web/app/components/custom/custom-page/__tests__/index.spec.tsx b/web/app/components/custom/custom-page/__tests__/index.spec.tsx index 28d77862fa5..b68a53937cf 100644 --- a/web/app/components/custom/custom-page/__tests__/index.spec.tsx +++ b/web/app/components/custom/custom-page/__tests__/index.spec.tsx @@ -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) { + args[0] = {args[0]} + 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) }) // 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', () => { diff --git a/web/app/components/custom/custom-page/index.tsx b/web/app/components/custom/custom-page/index.tsx index a663bb2c968..e8042bc2aa4 100644 --- a/web/app/components/custom/custom-page/index.tsx +++ b/web/app/components/custom/custom-page/index.tsx @@ -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 = () => { diff --git a/web/app/components/datasets/create/step-one/__tests__/index.spec.tsx b/web/app/components/datasets/create/step-one/__tests__/index.spec.tsx index 56aa9b22bad..85b765a86b6 100644 --- a/web/app/components/datasets/create/step-one/__tests__/index.spec.tsx +++ b/web/app/components/datasets/create/step-one/__tests__/index.spec.tsx @@ -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 }) => ( + + {children} + + ), + }) } // Mock config for website crawl features diff --git a/web/app/components/datasets/create/step-one/__tests__/upgrade-card.spec.tsx b/web/app/components/datasets/create/step-one/__tests__/upgrade-card.spec.tsx index 65d97b30c5a..8bded1cdf48 100644 --- a/web/app/components/datasets/create/step-one/__tests__/upgrade-card.spec.tsx +++ b/web/app/components/datasets/create/step-one/__tests__/upgrade-card.spec.tsx @@ -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 }) => ( diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index 98dd2b88bf6..3c56b76e197 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -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() - return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] } + return { + ...actual, + useQueryState: (...args: Parameters) => + 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[1]>['systemFeatures'], + NonNullable[1]>['systemFeatures'], null | undefined > @@ -539,7 +547,7 @@ const renderMainNav = ( options: { store?: ReturnType extra?: ReactNode - educationStatus?: NonNullable[1]>['educationStatus'] + educationStatus?: NonNullable[1]>['educationStatus'] skipRecoveryVisible?: boolean } = {}, ) => { @@ -585,7 +593,7 @@ const renderMainNav = ( ...systemFeatures.branding, }, } - return renderWithConsoleQuery( + return render( {options.extra} @@ -604,6 +612,11 @@ const renderMainNav = ( ) } +function render(...args: Parameters) { + args[0] = {args[0]} + 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) }) diff --git a/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx b/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx index 00ed59f374f..d91f1d0fc45 100644 --- a/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx +++ b/web/app/components/main-nav/components/__tests__/support-menu.spec.tsx @@ -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) { + args[0] = {args[0]} + 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', () => { diff --git a/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx b/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx index 5419491fbe3..1662524248b 100644 --- a/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx +++ b/web/app/components/main-nav/components/__tests__/workspace-card.spec.tsx @@ -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() 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() - return { ...actual, useQueryState: () => [null, mockSetSettingsDestination] } + return { + ...actual, + useQueryState: (...args: Parameters) => + 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[1] & { +type RenderWorkspaceCardOptions = Parameters[1] & { seedWorkspaces?: boolean systemFeaturesLicense?: Parameters[1] } @@ -133,7 +133,7 @@ const renderWorkspaceCard = (options?: RenderWorkspaceCardOptions) => { queryClient.setQueryData(consoleQuery.workspaces.get.queryKey(), { workspaces: mockWorkspaces }) if (systemFeaturesLicense) seedSystemFeaturesLicense(queryClient, systemFeaturesLicense) - return renderWithConsoleQuery(, { + return render(, { ...renderOptions, queryClient, currentWorkspace: mockCurrentWorkspace ? undefined : null, @@ -146,6 +146,11 @@ const mockWorkspacePermissionKeys = (workspacePermissionKeys: string[]) => { } } +function render(...args: Parameters) { + args[0] = {args[0]} + 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', () => { diff --git a/web/app/components/main-nav/components/support-menu.tsx b/web/app/components/main-nav/components/support-menu.tsx index 571fa4d33b2..928e6334bdf 100644 --- a/web/app/components/main-nav/components/support-menu.tsx +++ b/web/app/components/main-nav/components/support-menu.tsx @@ -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 && ( $['userProfile.contactUs'], { ns: 'common' })} ${t(($) => $['upgradeBtn.encourageShort'], { ns: 'billing' })}`} className="mx-0 h-8 gap-1 px-3 py-1" onClick={() => { - setShowPricingModal() + setPricing('open') }} > } trailing={ - + {t(($) => $['upgradeBtn.encourageShort'], { ns: 'billing' })} } diff --git a/web/app/components/main-nav/components/workspace-card.tsx b/web/app/components/main-nav/components/workspace-card.tsx index 113d2c948e2..9edcf19652e 100644 --- a/web/app/components/main-nav/components/workspace-card.tsx +++ b/web/app/components/main-nav/components/workspace-card.tsx @@ -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')} /> 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: ( - 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({ui}) } +function render(...args: Parameters) { + args[0] = {args[0]} + 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 () => { diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx index 0a7c4dfabd0..97daa81e3ff 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/__tests__/popup.spec.tsx @@ -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: ( - 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: () => , })) +function render(...args: Parameters) { + args[0] = {args[0]} + 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() @@ -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', () => { diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx index 48321d393ea..478df63029e 100644 --- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx +++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx @@ -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 (
{ 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'], diff --git a/web/app/components/step-by-step-tour/mount.tsx b/web/app/components/step-by-step-tour/mount.tsx index ad7be6d2ea3..15c652c0865 100644 --- a/web/app/components/step-by-step-tour/mount.tsx +++ b/web/app/components/step-by-step-tour/mount.tsx @@ -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 diff --git a/web/app/components/tools/edit-custom-collection-modal/__tests__/index.spec.tsx b/web/app/components/tools/edit-custom-collection-modal/__tests__/index.spec.tsx index 8cdb13f4e36..c0015ce8647 100644 --- a/web/app/components/tools/edit-custom-collection-modal/__tests__/index.spec.tsx +++ b/web/app/components/tools/edit-custom-collection-modal/__tests__/index.spec.tsx @@ -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('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) { + args[0] = {args[0]} + return renderWithoutPricing(...args) +} + describe('EditCustomCollectionModal', () => { const mockOnHide = vi.fn() const mockOnAdd = vi.fn() diff --git a/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx b/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx index df2ab245422..315b4f5bea0 100644 --- a/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx +++ b/web/app/components/workflow/header/__tests__/header-in-restoring.spec.tsx @@ -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({ui}, { ...options, queryClient }) } diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/index.spec.tsx index 45980de8b39..9c61bc215ed 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/index.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/index.spec.tsx @@ -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 }) => ( + + {children} + + ), + }) +} diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/upgrade-modal.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/upgrade-modal.spec.tsx index 40c59f5d291..44d7a4fb3b7 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/upgrade-modal.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/upgrade-modal.spec.tsx @@ -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() - - 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( + + + , + { 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'), + ) }) diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/upgrade-modal.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/upgrade-modal.tsx index d1a1c6a2b1e..992b1f3c7b1 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/upgrade-modal.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/upgrade-modal.tsx @@ -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 ( diff --git a/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx b/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx index 0058c3b122d..15c525e4b39 100644 --- a/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx +++ b/web/app/components/workflow/panel/version-history-panel/__tests__/index.spec.tsx @@ -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 }) => ( + + {children} + + ), + }) } diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/index.spec.tsx b/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/index.spec.tsx index ed81103cc30..5bb89b3efd0 100644 --- a/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/index.spec.tsx +++ b/web/app/components/workflow/panel/version-history-panel/action-menu/__tests__/index.spec.tsx @@ -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({ui}, { queryClient }) } vi.mock('@/config', async (importOriginal) => { diff --git a/web/app/education/expire-notice/__tests__/index.spec.tsx b/web/app/education/expire-notice/__tests__/index.spec.tsx index 5cdc338c779..6229b9fb369 100644 --- a/web/app/education/expire-notice/__tests__/index.spec.tsx +++ b/web/app/education/expire-notice/__tests__/index.spec.tsx @@ -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(, { wrapper }) + return render( + + + , + { wrapper }, + ) } describe('EducationExpireNotice', () => { diff --git a/web/app/education/expire-notice/__tests__/modal.spec.tsx b/web/app/education/expire-notice/__tests__/modal.spec.tsx index 04b17757708..824b169d5b4 100644 --- a/web/app/education/expire-notice/__tests__/modal.spec.tsx +++ b/web/app/education/expire-notice/__tests__/modal.spec.tsx @@ -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(, { - wrapper, - }) + render( + + + , + { + wrapper, + }, + ) expect(screen.getByRole('link', { name: 'education.notice.action.reVerify' })).toHaveAttribute( 'href', diff --git a/web/app/education/expire-notice/index.tsx b/web/app/education/expire-notice/index.tsx index c37f9489f24..4ee236a23bc 100644 --- a/web/app/education/expire-notice/index.tsx +++ b/web/app/education/expire-notice/index.tsx @@ -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 ( = ({ 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 ( = ({ expireAt, expired, onClose }) => {