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 87e1db69bf..ebf8afa6d8 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,5 +1,6 @@ import type { VersionHistory } from '@/types/workflow' -import { screen } from '@testing-library/react' +import { fireEvent, screen } from '@testing-library/react' +import { Plan } from '@/app/components/billing/type' import { FlowType } from '@/types/common' import { renderWorkflowComponent } from '../../__tests__/workflow-test-env' import { WorkflowVersion } from '../../types' @@ -10,6 +11,15 @@ const mockInvalidAllLastRun = vi.fn() const mockResetWorkflowVersionHistory = vi.fn() const mockHandleLoadBackupDraft = vi.fn() const mockHandleRefreshWorkflowDraft = vi.fn() +let mockPlanType = Plan.professional +let mockEnableBilling = true + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + plan: { type: mockPlanType }, + enableBilling: mockEnableBilling, + }), +})) vi.mock('@/hooks/use-theme', () => ({ default: () => ({ @@ -75,6 +85,8 @@ const createVersion = (overrides: Partial = {}): VersionHistory describe('HeaderInRestoring', () => { beforeEach(() => { vi.clearAllMocks() + mockPlanType = Plan.professional + mockEnableBilling = true }) it('should disable restore when the flow id is not ready yet', () => { @@ -125,4 +137,26 @@ describe('HeaderInRestoring', () => { expect(screen.getByRole('button', { name: 'workflow.common.restore' })).toBeDisabled() }) + + it('should show plan upgrade modal instead of restoring when sandbox users click restore', () => { + mockPlanType = Plan.sandbox + renderWorkflowComponent(, { + initialStoreState: { + currentVersion: createVersion(), + }, + hooksStoreProps: { + configsMap: { + flowId: 'app-1', + flowType: FlowType.appFlow, + fileSettings: {} as never, + }, + }, + }) + + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.restore' })) + + expect(screen.getByText('billing.upgrade.workflowRestore.title')).toBeInTheDocument() + expect(mockRestoreWorkflow).not.toHaveBeenCalled() + expect(mockHandleRefreshWorkflowDraft).not.toHaveBeenCalled() + }) }) diff --git a/web/app/components/workflow/header/header-in-restoring.tsx b/web/app/components/workflow/header/header-in-restoring.tsx index bb3753ccfb..b098bed601 100644 --- a/web/app/components/workflow/header/header-in-restoring.tsx +++ b/web/app/components/workflow/header/header-in-restoring.tsx @@ -4,9 +4,13 @@ import { toast } from '@langgenius/dify-ui/toast' import { RiHistoryLine } from '@remixicon/react' import { useCallback, + useState, } from 'react' import { useTranslation } from 'react-i18next' +import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal' +import { Plan } from '@/app/components/billing/type' import { useSelector as useAppContextSelector } from '@/context/app-context' +import { useProviderContext } from '@/context/provider-context' import useTheme from '@/hooks/use-theme' import { useInvalidAllLastRun, useResetWorkflowVersionHistory, useRestoreWorkflow } from '@/service/use-workflow' import { FlowType } from '@/types/common' @@ -32,6 +36,8 @@ const HeaderInRestoring = ({ }: HeaderInRestoringProps) => { const { t } = useTranslation() const { theme } = useTheme() + const [isRestorePlanUpgradeModalOpen, setIsRestorePlanUpgradeModalOpen] = useState(false) + const { plan, enableBilling } = useProviderContext() const workflowStore = useWorkflowStore() const userProfile = useAppContextSelector(s => s.userProfile) const configsMap = useHooksStore(s => s.configsMap) @@ -49,6 +55,7 @@ const HeaderInRestoring = ({ const { mutateAsync: restoreWorkflow } = useRestoreWorkflow() const resetWorkflowVersionHistory = useResetWorkflowVersionHistory() const canRestore = !!currentVersion?.id && !!configsMap?.flowId && currentVersion.version !== WorkflowVersion.Draft + const canUseWorkflowVersionAction = !enableBilling || plan.type !== Plan.sandbox const canEmitCollaborationEvents = configsMap?.flowType === FlowType.appFlow const handleCancelRestore = useCallback(() => { @@ -116,6 +123,11 @@ const HeaderInRestoring = ({ if (!canRestore || !currentVersion) return + if (!canUseWorkflowVersionAction) { + setIsRestorePlanUpgradeModalOpen(true) + return + } + setShowWorkflowVersionHistoryPanel(false) await emitRestoreIntent() @@ -138,7 +150,7 @@ const HeaderInRestoring = ({ resetWorkflowVersionHistory() onRestoreSettled?.() } - }, [canRestore, currentVersion, setShowWorkflowVersionHistoryPanel, emitRestoreIntent, restoreWorkflow, restoreVersionUrl, workflowStore, handleRefreshWorkflowDraft, t, deleteAllInspectVars, invalidAllLastRun, emitRestoreComplete, emitWorkflowUpdate, resetWorkflowVersionHistory, onRestoreSettled]) + }, [canRestore, currentVersion, canUseWorkflowVersionAction, setShowWorkflowVersionHistoryPanel, emitRestoreIntent, restoreWorkflow, restoreVersionUrl, workflowStore, handleRefreshWorkflowDraft, t, deleteAllInspectVars, invalidAllLastRun, emitRestoreComplete, emitWorkflowUpdate, resetWorkflowVersionHistory, onRestoreSettled]) return ( <> @@ -170,6 +182,14 @@ const HeaderInRestoring = ({ + {isRestorePlanUpgradeModalOpen && ( + setIsRestorePlanUpgradeModalOpen(false)} + title={t('upgrade.workflowRestore.title', { ns: 'billing' })!} + description={t('upgrade.workflowRestore.description', { ns: 'billing' })!} + /> + )} ) } 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 3c9f7dba0e..3bd17d4947 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 @@ -1,16 +1,23 @@ import type { Shape } from '../../../store' import type { VersionHistory } from '@/types/workflow' import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' +import { Plan } from '@/app/components/billing/type' import { VersionHistoryContextMenuOptions, WorkflowVersion } from '../../../types' const mockHandleRestoreFromPublishedWorkflow = vi.fn() const mockHandleLoadBackupDraft = vi.fn() const mockHandleRefreshWorkflowDraft = vi.fn() +const mockHandleExportDSL = vi.fn() const mockRestoreWorkflow = vi.fn() const mockSetCurrentVersion = vi.fn() const mockSetShowWorkflowVersionHistoryPanel = vi.fn() const mockWorkflowStoreSetState = vi.fn() +const mockEmitRestoreIntent = vi.fn() +const mockEmitRestoreComplete = vi.fn() +const mockEmitWorkflowUpdate = vi.fn() +let mockPlanType = Plan.professional +let mockEnableBilling = true const createVersionHistory = (overrides: Partial = {}): VersionHistory => ({ id: 'version-id', @@ -56,6 +63,13 @@ vi.mock('@/context/app-context', () => ({ useSelector: () => ({ id: 'test-user-id' }), })) +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + plan: { type: mockPlanType }, + enableBilling: mockEnableBilling, + }), +})) + vi.mock('@/service/use-workflow', () => ({ useDeleteWorkflow: () => ({ mutateAsync: vi.fn() }), useInvalidAllLastRun: () => vi.fn(), @@ -88,7 +102,7 @@ vi.mock('@/service/use-workflow', () => ({ })) vi.mock('../../../hooks', () => ({ - useDSL: () => ({ handleExportDSL: vi.fn() }), + useDSL: () => ({ handleExportDSL: mockHandleExportDSL }), useWorkflowRefreshDraft: () => ({ handleRefreshWorkflowDraft: mockHandleRefreshWorkflowDraft }), useWorkflowRun: () => ({ handleRestoreFromPublishedWorkflow: mockHandleRestoreFromPublishedWorkflow, @@ -103,6 +117,14 @@ vi.mock('../../../hooks-store', () => ({ }), })) +vi.mock('../../../collaboration/core/collaboration-manager', () => ({ + collaborationManager: { + emitRestoreIntent: mockEmitRestoreIntent, + emitRestoreComplete: mockEmitRestoreComplete, + emitWorkflowUpdate: mockEmitWorkflowUpdate, + }, +})) + vi.mock('../../../store', () => ({ useStore: (selector: (state: MockVersionStoreState) => T) => { const state: MockVersionStoreState = { @@ -149,19 +171,27 @@ vi.mock('../version-history-item', () => ({ default: (props: MockVersionHistoryItemProps) => { const MockVersionHistoryItem = () => { const { item, onClick, handleClickActionMenuItem } = props + const didSelectDraftRef = useRef(false) useEffect(() => { - if (item.version === WorkflowVersion.Draft) + if (item.version === WorkflowVersion.Draft && !didSelectDraftRef.current) { + didSelectDraftRef.current = true onClick(item) + } }, [item, onClick]) return (
{item.version !== WorkflowVersion.Draft && ( - + <> + + + )}
) @@ -174,7 +204,10 @@ vi.mock('../version-history-item', () => ({ describe('VersionHistoryPanel', () => { beforeEach(() => { vi.clearAllMocks() + mockRestoreWorkflow.mockResolvedValue(undefined) mockCurrentVersion = null + mockPlanType = Plan.professional + mockEnableBilling = true }) describe('Version Click Behavior', () => { @@ -221,6 +254,9 @@ describe('VersionHistoryPanel', () => { />, ) + await waitFor(() => { + expect(mockHandleLoadBackupDraft).toHaveBeenCalled() + }) vi.clearAllMocks() fireEvent.click(screen.getByText('restore-published-version-id')) @@ -237,9 +273,47 @@ describe('VersionHistoryPanel', () => { }) }) + it('should show plan upgrade modal instead of restore confirmation for sandbox users', async () => { + const { VersionHistoryPanel } = await import('../index') + mockPlanType = Plan.sandbox + + render( + `/apps/app-1/workflows/${versionId}/restore`} + />, + ) + + vi.clearAllMocks() + + fireEvent.click(screen.getByText('restore-published-version-id')) + + expect(screen.getByText('billing.upgrade.workflowRestore.title')).toBeInTheDocument() + expect(screen.queryByText('confirm restore')).not.toBeInTheDocument() + expect(mockRestoreWorkflow).not.toHaveBeenCalled() + }) + + it('should show plan upgrade modal instead of exporting DSL for sandbox users', async () => { + const { VersionHistoryPanel } = await import('../index') + mockPlanType = Plan.sandbox + + render( + `/apps/app-1/workflows/${versionId}/restore`} + />, + ) + + vi.clearAllMocks() + + fireEvent.click(screen.getByText('export-published-version-id')) + + expect(screen.getByText('billing.upgrade.workflowRestore.title')).toBeInTheDocument() + expect(mockHandleExportDSL).not.toHaveBeenCalled() + }) + it('should keep restore mode backup state when restore request fails', async () => { const { VersionHistoryPanel } = await import('../index') - mockRestoreWorkflow.mockRejectedValueOnce(new Error('restore failed')) mockCurrentVersion = createVersionHistory({ id: 'draft-version-id', version: WorkflowVersion.Draft, @@ -253,6 +327,7 @@ describe('VersionHistoryPanel', () => { ) vi.clearAllMocks() + mockRestoreWorkflow.mockRejectedValueOnce(new Error('restore failed')) fireEvent.click(screen.getByText('restore-published-version-id')) fireEvent.click(screen.getByText('confirm restore')) 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 42952f373b..5a633f36bc 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,10 +1,35 @@ import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { Plan } from '@/app/components/billing/type' import { renderWorkflowComponent } from '../../../../__tests__/workflow-test-env' import { VersionHistoryContextMenuOptions } from '../../../../types' import ActionMenu from '../index' +vi.mock('@/config', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + IS_CLOUD_EDITION: true, + } +}) + +let mockPlanType = Plan.professional +let mockEnableBilling = true + +vi.mock('@/context/provider-context', () => ({ + useProviderContext: () => ({ + plan: { type: mockPlanType }, + enableBilling: mockEnableBilling, + }), +})) + describe('ActionMenu', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPlanType = Plan.professional + mockEnableBilling = true + }) + it('toggles the trigger and forwards menu clicks', async () => { const user = userEvent.setup() const setOpen = vi.fn() @@ -34,4 +59,27 @@ describe('ActionMenu', () => { VersionHistoryContextMenuOptions.delete, ) }) + + it('shows upgrade buttons beside restore and export for sandbox users', async () => { + const user = userEvent.setup() + const handleClickActionMenuItem = vi.fn() + mockPlanType = Plan.sandbox + + renderWorkflowComponent( + , + ) + + const upgradeButtons = screen.getAllByRole('button', { name: 'billing.upgradeBtn.encourageShort' }) + expect(upgradeButtons).toHaveLength(2) + + await user.click(upgradeButtons[0]!) + + expect(handleClickActionMenuItem).not.toHaveBeenCalled() + }) }) diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/action-menu-item.tsx b/web/app/components/workflow/panel/version-history-panel/action-menu/action-menu-item.tsx index 7d24e81217..a1fc8673ef 100644 --- a/web/app/components/workflow/panel/version-history-panel/action-menu/action-menu-item.tsx +++ b/web/app/components/workflow/panel/version-history-panel/action-menu/action-menu-item.tsx @@ -3,11 +3,13 @@ import type { VersionHistoryContextMenuOptions } from '../../../types' import { cn } from '@langgenius/dify-ui/cn' import { DropdownMenuItem } from '@langgenius/dify-ui/dropdown-menu' import * as React from 'react' +import UpgradeBtn from '@/app/components/billing/upgrade-btn' type ActionMenuItemProps = { item: { key: VersionHistoryContextMenuOptions name: string + showUpgrade?: boolean } onClick: (operation: VersionHistoryContextMenuOptions) => void isDestructive?: boolean @@ -22,21 +24,38 @@ const ActionMenuItem: FC = ({ { event.stopPropagation() + const target = event.target + if (target instanceof Element && target.closest('[data-upgrade-action]')) + return + onClick(item.key) }} >
{item.name}
+ {item.showUpgrade && ( +
+ +
+ )}
) } diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx b/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx index 4af7f9bc52..81f04f31ea 100644 --- a/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx +++ b/web/app/components/workflow/panel/version-history-panel/action-menu/index.tsx @@ -41,7 +41,7 @@ const ActionMenu: FC = (props: ActionMenuProps) => { { options.map(option => ( diff --git a/web/app/components/workflow/panel/version-history-panel/action-menu/use-action-menu.ts b/web/app/components/workflow/panel/version-history-panel/action-menu/use-action-menu.ts index 4a81809aeb..765816427a 100644 --- a/web/app/components/workflow/panel/version-history-panel/action-menu/use-action-menu.ts +++ b/web/app/components/workflow/panel/version-history-panel/action-menu/use-action-menu.ts @@ -1,7 +1,9 @@ import type { ActionMenuProps } from './index' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' +import { Plan } from '@/app/components/billing/type' import { useStore } from '@/app/components/workflow/store' +import { useProviderContext } from '@/context/provider-context' import { VersionHistoryContextMenuOptions } from '../../../types' const useActionMenu = (props: ActionMenuProps) => { @@ -10,6 +12,8 @@ const useActionMenu = (props: ActionMenuProps) => { } = props const { t } = useTranslation() const pipelineId = useStore(s => s.pipelineId) + const { plan, enableBilling } = useProviderContext() + const shouldShowUpgrade = enableBilling && plan.type === Plan.sandbox const deleteOperation = { key: VersionHistoryContextMenuOptions.delete, @@ -21,6 +25,7 @@ const useActionMenu = (props: ActionMenuProps) => { { key: VersionHistoryContextMenuOptions.restore, name: t('common.restore', { ns: 'workflow' }), + ...(shouldShowUpgrade ? { showUpgrade: true } : {}), }, isNamedVersion ? { @@ -36,6 +41,7 @@ const useActionMenu = (props: ActionMenuProps) => { ? [{ key: VersionHistoryContextMenuOptions.exportDSL, name: t('export', { ns: 'app' }), + ...(shouldShowUpgrade ? { showUpgrade: true } : {}), }] : []), { @@ -43,7 +49,7 @@ const useActionMenu = (props: ActionMenuProps) => { name: t('versionHistory.copyId', { ns: 'workflow' }), }, ] - }, [isNamedVersion, pipelineId, t]) + }, [isNamedVersion, pipelineId, shouldShowUpgrade, t]) return { deleteOperation, diff --git a/web/app/components/workflow/panel/version-history-panel/index.tsx b/web/app/components/workflow/panel/version-history-panel/index.tsx index 568d12fd95..05c7dce729 100644 --- a/web/app/components/workflow/panel/version-history-panel/index.tsx +++ b/web/app/components/workflow/panel/version-history-panel/index.tsx @@ -8,7 +8,10 @@ import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import VersionInfoModal from '@/app/components/app/app-publisher/version-info-modal' import Divider from '@/app/components/base/divider' +import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal' +import { Plan } from '@/app/components/billing/type' import { useSelector as useAppContextSelector } from '@/context/app-context' +import { useProviderContext } from '@/context/provider-context' import { useDeleteWorkflow, useInvalidAllLastRun, useResetWorkflowVersionHistory, useRestoreWorkflow, useUpdateWorkflow, useWorkflowVersionHistory } from '@/service/use-workflow' import { useDSL, useWorkflowRefreshDraft, useWorkflowRun } from '../../hooks' import { useHooksStore } from '../../hooks-store' @@ -43,8 +46,11 @@ export const VersionHistoryPanel = ({ const [isOnlyShowNamedVersions, setIsOnlyShowNamedVersions] = useState(false) const [operatedItem, setOperatedItem] = useState() const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false) + const [isRestorePlanUpgradeModalOpen, setIsRestorePlanUpgradeModalOpen] = useState(false) const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) const [editModalOpen, setEditModalOpen] = useState(false) + const { plan, enableBilling } = useProviderContext() + const canUseWorkflowVersionAction = !enableBilling || plan.type !== Plan.sandbox const workflowStore = useWorkflowStore() const { handleRestoreFromPublishedWorkflow, handleLoadBackupDraft } = useWorkflowRun() const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft() @@ -111,6 +117,10 @@ export const VersionHistoryPanel = ({ setOperatedItem(item) switch (operation) { case VersionHistoryContextMenuOptions.restore: + if (!canUseWorkflowVersionAction) { + setIsRestorePlanUpgradeModalOpen(true) + break + } setRestoreConfirmOpen(true) break case VersionHistoryContextMenuOptions.edit: @@ -124,10 +134,14 @@ export const VersionHistoryPanel = ({ toast.success(t('versionHistory.action.copyIdSuccess', { ns: 'workflow' })) break case VersionHistoryContextMenuOptions.exportDSL: + if (!canUseWorkflowVersionAction) { + setIsRestorePlanUpgradeModalOpen(true) + break + } handleExportDSL?.(false, item.id) break } - }, [t, handleExportDSL]) + }, [canUseWorkflowVersionAction, t, handleExportDSL]) const handleCancel = useCallback((operation: VersionHistoryContextMenuOptions) => { switch (operation) { @@ -330,6 +344,14 @@ export const VersionHistoryPanel = ({ onRestore={handleRestore} /> )} + {isRestorePlanUpgradeModalOpen && ( + setIsRestorePlanUpgradeModalOpen(false)} + title={t('upgrade.workflowRestore.title', { ns: 'billing' })!} + description={t('upgrade.workflowRestore.description', { ns: 'billing' })!} + /> + )} {deleteConfirmOpen && (