From 8e2b8168be162aee6f2b7efc17e7624443384098 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Tue, 5 May 2026 16:50:49 +0800 Subject: [PATCH] refactor(web): migrate HITL overlays to base dialog (#35792) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 56 --- .../billing/billing-integration.test.tsx | 2 +- .../components/base/upgrade-modal/index.tsx | 75 ++++ .../upgrade-modal}/style.module.css | 0 .../__tests__/index.spec.tsx | 22 +- .../billing/plan-upgrade-modal/index.tsx | 85 ++--- .../__tests__/index.spec.tsx | 44 +-- .../trigger-events-limit-modal/index.tsx | 12 +- .../create/step-one/__tests__/index.spec.tsx | 14 +- .../__tests__/preview-panel.spec.tsx | 15 +- .../step-one/components/preview-panel.tsx | 2 +- .../__tests__/index.spec.tsx | 12 - .../documents/create-from-pipeline/index.tsx | 2 +- .../segment-add/__tests__/index.spec.tsx | 23 +- .../documents/detail/segment-add/index.tsx | 2 +- .../human-input/__tests__/panel.spec.tsx | 5 - .../__tests__/email-configure-modal.spec.tsx | 17 +- .../delivery-method/__tests__/index.spec.tsx | 24 +- .../__tests__/method-item.spec.tsx | 24 +- .../__tests__/method-selector.spec.tsx | 2 +- .../__tests__/test-email-sender.spec.tsx | 298 +++++++++++++++ .../__tests__/upgrade-modal.spec.tsx | 8 +- .../delivery-method/email-configure-modal.tsx | 168 ++++----- .../components/delivery-method/index.tsx | 32 +- .../delivery-method/method-item.tsx | 130 +++---- .../delivery-method/method-selector.tsx | 54 ++- .../delivery-method/test-email-sender.tsx | 349 +++++++++--------- .../delivery-method/upgrade-modal.tsx | 106 +++--- .../workflow/nodes/human-input/panel.tsx | 14 +- web/context/modal-context-provider.tsx | 6 +- web/context/modal-context.test.tsx | 78 ++-- web/context/modal-context.ts | 4 +- 32 files changed, 923 insertions(+), 762 deletions(-) create mode 100644 web/app/components/base/upgrade-modal/index.tsx rename web/app/components/{billing/plan-upgrade-modal => base/upgrade-modal}/style.module.css (100%) create mode 100644 web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/test-email-sender.spec.tsx diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 586590413f..bbb5cd5af9 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1969,11 +1969,6 @@ "count": 4 } }, - "web/app/components/billing/plan-upgrade-modal/index.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/billing/plan/assets/index.tsx": { "no-barrel-files/no-barrel-files": { "count": 4 @@ -4195,37 +4190,6 @@ "count": 5 } }, - "web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/human-input/components/delivery-method/method-item.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/human-input/components/delivery-method/method-selector.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 2 - }, - "ts/no-non-null-asserted-optional-chain": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/human-input/components/delivery-method/upgrade-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/human-input/components/form-content-preview.tsx": { "react/unsupported-syntax": { "count": 1 @@ -4250,11 +4214,6 @@ "count": 2 } }, - "web/app/components/workflow/nodes/human-input/panel.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/human-input/types.ts": { "erasable-syntax-only/enums": { "count": 2 @@ -5284,21 +5243,6 @@ "count": 3 } }, - "web/context/modal-context-provider.tsx": { - "ts/no-explicit-any": { - "count": 3 - } - }, - "web/context/modal-context.test.tsx": { - "ts/no-explicit-any": { - "count": 5 - } - }, - "web/context/modal-context.ts": { - "ts/no-explicit-any": { - "count": 2 - } - }, "web/context/provider-context-provider.tsx": { "ts/no-explicit-any": { "count": 1 diff --git a/web/__tests__/billing/billing-integration.test.tsx b/web/__tests__/billing/billing-integration.test.tsx index 90589ae1e4..3113e36751 100644 --- a/web/__tests__/billing/billing-integration.test.tsx +++ b/web/__tests__/billing/billing-integration.test.tsx @@ -9,7 +9,7 @@ import Billing from '@/app/components/billing/billing-page' import { defaultPlan, NUM_INFINITE } from '@/app/components/billing/config' import HeaderBillingBtn from '@/app/components/billing/header-billing-btn' import PlanComp from '@/app/components/billing/plan' -import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal' +import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal' import PriorityLabel from '@/app/components/billing/priority-label' import TriggerEventsLimitModal from '@/app/components/billing/trigger-events-limit-modal' import { Plan } from '@/app/components/billing/type' diff --git a/web/app/components/base/upgrade-modal/index.tsx b/web/app/components/base/upgrade-modal/index.tsx new file mode 100644 index 0000000000..cfae72eaf7 --- /dev/null +++ b/web/app/components/base/upgrade-modal/index.tsx @@ -0,0 +1,75 @@ +'use client' + +import type { ComponentType, ReactNode } from 'react' +import { cn } from '@langgenius/dify-ui/cn' +import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog' +import styles from './style.module.css' + +type UpgradeModalClassNames = { + content?: string + heroOverlay?: string + body?: string + icon?: string + copy?: string + title?: string + description?: string + footer?: string +} + +type UpgradeModalProps = { + open: boolean + onOpenChange?: (open: boolean) => void + Icon?: ComponentType<{ className?: string }> + title: ReactNode + description: ReactNode + extraInfo?: ReactNode + footer: ReactNode + classNames?: UpgradeModalClassNames +} + +export function UpgradeModal({ + open, + onOpenChange, + Icon, + title, + description, + extraInfo, + footer, + classNames, +}: UpgradeModalProps) { + return ( + + +
+
+
+ {Icon && ( +
+ +
+ )} +
+ + {title} + + + {description} + +
+ {extraInfo} +
+
+ +
+ {footer} +
+ +
+ ) +} diff --git a/web/app/components/billing/plan-upgrade-modal/style.module.css b/web/app/components/base/upgrade-modal/style.module.css similarity index 100% rename from web/app/components/billing/plan-upgrade-modal/style.module.css rename to web/app/components/base/upgrade-modal/style.module.css diff --git a/web/app/components/billing/plan-upgrade-modal/__tests__/index.spec.tsx b/web/app/components/billing/plan-upgrade-modal/__tests__/index.spec.tsx index b28ffffa53..2e8b7777ee 100644 --- a/web/app/components/billing/plan-upgrade-modal/__tests__/index.spec.tsx +++ b/web/app/components/billing/plan-upgrade-modal/__tests__/index.spec.tsx @@ -1,19 +1,9 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import * as React from 'react' -import PlanUpgradeModal from '../index' +import { PlanUpgradeModal } from '../index' const mockSetShowPricingModal = vi.fn() -vi.mock('@/app/components/base/modal', () => { - const MockModal = ({ isShow, children }: { isShow: boolean, children: React.ReactNode }) => ( - isShow ?
{children}
: null - ) - return { - default: MockModal, - } -}) - vi.mock('@/context/modal-context', () => ({ useModalContext: () => ({ setShowPricingModal: mockSetShowPricingModal, @@ -70,6 +60,16 @@ describe('PlanUpgradeModal', () => { expect(onClose).toHaveBeenCalledTimes(1) }) + it('should call onClose when dialog requests close', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + renderComponent({ onClose }) + + await user.keyboard('{Escape}') + + expect(onClose).toHaveBeenCalledTimes(1) + }) + // Upgrade path uses provided callback over pricing modal it('should call onUpgrade and onClose when upgrade button is clicked with onUpgrade provided', async () => { const user = userEvent.setup() diff --git a/web/app/components/billing/plan-upgrade-modal/index.tsx b/web/app/components/billing/plan-upgrade-modal/index.tsx index da599dc36c..0d40edcfad 100644 --- a/web/app/components/billing/plan-upgrade-modal/index.tsx +++ b/web/app/components/billing/plan-upgrade-modal/index.tsx @@ -1,26 +1,24 @@ 'use client' -import type { FC } from 'react' +import type { ComponentType, ReactNode } from 'react' import { Button } from '@langgenius/dify-ui/button' -import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import Modal from '@/app/components/base/modal' +import { UpgradeModal } from '@/app/components/base/upgrade-modal' import UpgradeBtn from '@/app/components/billing/upgrade-btn' import { useModalContext } from '@/context/modal-context' import { SquareChecklist } from '../../base/icons/src/vender/other' -import styles from './style.module.css' type Props = { - Icon?: React.ComponentType> + Icon?: ComponentType<{ className?: string }> title: string description: string - extraInfo?: React.ReactNode + extraInfo?: ReactNode show: boolean onClose: () => void onUpgrade?: () => void } -const PlanUpgradeModal: FC = ({ +export function PlanUpgradeModal({ Icon = SquareChecklist, title, description, @@ -28,7 +26,7 @@ const PlanUpgradeModal: FC = ({ show, onClose, onUpgrade, -}) => { +}: Props) { const { t } = useTranslation() const { setShowPricingModal } = useModalContext() @@ -41,51 +39,30 @@ const PlanUpgradeModal: FC = ({ }, [onClose, onUpgrade, setShowPricingModal]) return ( - -
-
-
-
- -
-
-
- {title} -
-
- {description} -
-
- {extraInfo} -
-
- -
- - -
- + !open && onClose()} + Icon={Icon} + title={title} + description={description} + extraInfo={extraInfo} + footer={( + <> + + + + )} + /> ) } - -export default React.memo(PlanUpgradeModal) diff --git a/web/app/components/billing/trigger-events-limit-modal/__tests__/index.spec.tsx b/web/app/components/billing/trigger-events-limit-modal/__tests__/index.spec.tsx index 47577a5b48..a8dfcd63e6 100644 --- a/web/app/components/billing/trigger-events-limit-modal/__tests__/index.spec.tsx +++ b/web/app/components/billing/trigger-events-limit-modal/__tests__/index.spec.tsx @@ -4,21 +4,6 @@ import TriggerEventsLimitModal from '../index' const mockOnClose = vi.fn() const mockOnUpgrade = vi.fn() -const planUpgradeModalMock = vi.fn((props: { show: boolean, title: string, description: string, extraInfo?: React.ReactNode, onClose: () => void, onUpgrade: () => void }) => ( -
- {props.extraInfo} -
-)) - -vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({ - default: (props: { show: boolean, title: string, description: string, extraInfo?: React.ReactNode, onClose: () => void, onUpgrade: () => void }) => planUpgradeModalMock(props), -})) - describe('TriggerEventsLimitModal', () => { beforeEach(() => { vi.clearAllMocks() @@ -36,16 +21,9 @@ describe('TriggerEventsLimitModal', () => { />, ) - const modal = screen.getByTestId('plan-upgrade-modal') - expect(modal.getAttribute('data-show')).toBe('true') - expect(modal.getAttribute('data-title')).toContain('billing.triggerLimitModal.title') - expect(modal.getAttribute('data-description')).toContain('billing.triggerLimitModal.description') - expect(planUpgradeModalMock).toHaveBeenCalled() - - const passedProps = planUpgradeModalMock.mock.calls[0]![0] - expect(passedProps.onClose).toBe(mockOnClose) - expect(passedProps.onUpgrade).toBe(mockOnUpgrade) - + expect(screen.getByRole('dialog')).toBeInTheDocument() + expect(screen.getByText('billing.triggerLimitModal.title')).toBeInTheDocument() + expect(screen.getByText('billing.triggerLimitModal.description')).toBeInTheDocument() expect(screen.getByText('billing.triggerLimitModal.usageTitle'))!.toBeInTheDocument() expect(screen.getByText('12'))!.toBeInTheDocument() expect(screen.getByText('20'))!.toBeInTheDocument() @@ -62,8 +40,7 @@ describe('TriggerEventsLimitModal', () => { />, ) - expect(planUpgradeModalMock).toHaveBeenCalled() - expect(screen.getByTestId('plan-upgrade-modal').getAttribute('data-show')).toBe('false') + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) it('renders reset info when resetInDays is provided', () => { @@ -94,9 +71,8 @@ describe('TriggerEventsLimitModal', () => { />, ) - const modal = screen.getByTestId('plan-upgrade-modal') - expect(modal.getAttribute('data-title')).toBe('billing.triggerLimitModal.title') - expect(modal.getAttribute('data-description')).toBe('billing.triggerLimitModal.description') + expect(screen.getByText('billing.triggerLimitModal.title')).toBeInTheDocument() + expect(screen.getByText('billing.triggerLimitModal.description')).toBeInTheDocument() }) it('passes onClose and onUpgrade callbacks to PlanUpgradeModal', () => { @@ -110,8 +86,10 @@ describe('TriggerEventsLimitModal', () => { />, ) - const passedProps = planUpgradeModalMock.mock.calls[0]![0] - expect(passedProps.onClose).toBe(mockOnClose) - expect(passedProps.onUpgrade).toBe(mockOnUpgrade) + screen.getByText('billing.triggerLimitModal.dismiss').click() + expect(mockOnClose).toHaveBeenCalledTimes(1) + + screen.getByText('billing.triggerLimitModal.upgrade').click() + expect(mockOnUpgrade).toHaveBeenCalledTimes(1) }) }) 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 5debb5cb57..9312f772b5 100644 --- a/web/app/components/billing/trigger-events-limit-modal/index.tsx +++ b/web/app/components/billing/trigger-events-limit-modal/index.tsx @@ -1,9 +1,7 @@ 'use client' -import type { FC } from 'react' -import * as React from 'react' import { useTranslation } from 'react-i18next' import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow' -import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal' +import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal' import UsageInfo from '@/app/components/billing/usage-info' type Props = { @@ -15,14 +13,14 @@ type Props = { resetInDays?: number } -const TriggerEventsLimitModal: FC = ({ +export default function TriggerEventsLimitModal({ show, onClose, onUpgrade, usage, total, resetInDays, -}) => { +}: Props) { const { t } = useTranslation() return ( @@ -30,7 +28,7 @@ const TriggerEventsLimitModal: FC = ({ show={show} onClose={onClose} onUpgrade={onUpgrade} - Icon={TriggerAll as React.ComponentType>} + Icon={TriggerAll} title={t('triggerLimitModal.title', { ns: 'billing' })} description={t('triggerLimitModal.description', { ns: 'billing' })} extraInfo={( @@ -47,5 +45,3 @@ const TriggerEventsLimitModal: FC = ({ /> ) } - -export default React.memo(TriggerEventsLimitModal) 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 f00ff121cc..6c6c60d808 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 @@ -92,18 +92,6 @@ vi.mock('@/app/components/billing/vector-space-full', () => ({ default: () =>
Vector Space Full
, })) -vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({ - default: ({ show, onClose }: { show: boolean, onClose: () => void }) => ( - show - ? ( -
- -
- ) - : null - ), -})) - vi.mock('../../file-preview', () => ({ default: ({ file, hidePreview }: { file: File, hidePreview: () => void }) => (
@@ -388,7 +376,7 @@ describe('StepOne', () => { fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })) - expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument() + expect(screen.getByRole('dialog')).toBeInTheDocument() }) it('should show upgrade card when in sandbox plan with files', () => { diff --git a/web/app/components/datasets/create/step-one/components/__tests__/preview-panel.spec.tsx b/web/app/components/datasets/create/step-one/components/__tests__/preview-panel.spec.tsx index f495dd9f3f..a807412008 100644 --- a/web/app/components/datasets/create/step-one/components/__tests__/preview-panel.spec.tsx +++ b/web/app/components/datasets/create/step-one/components/__tests__/preview-panel.spec.tsx @@ -31,17 +31,6 @@ vi.mock('../../../website/preview', () => ({ ), })) -vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({ - default: ({ show, onClose, title }: { show: boolean, onClose: () => void, title: string }) => show - ? ( -
- {title} - -
- ) - : null, -})) - const { default: PreviewPanel } = await import('../preview-panel') describe('PreviewPanel', () => { @@ -87,7 +76,7 @@ describe('PreviewPanel', () => { it('should render plan upgrade modal when isShowPlanUpgradeModal is true', () => { render() - expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument() + expect(screen.getByRole('dialog')).toBeInTheDocument() }) }) @@ -100,7 +89,7 @@ describe('PreviewPanel', () => { it('should call hidePlanUpgradeModal when modal close clicked', () => { render() - fireEvent.click(screen.getByTestId('close-modal')) + fireEvent.click(screen.getByRole('button', { name: 'billing.triggerLimitModal.dismiss' })) expect(defaultProps.hidePlanUpgradeModal).toHaveBeenCalledOnce() }) diff --git a/web/app/components/datasets/create/step-one/components/preview-panel.tsx b/web/app/components/datasets/create/step-one/components/preview-panel.tsx index 8ae0b7df55..51b6568154 100644 --- a/web/app/components/datasets/create/step-one/components/preview-panel.tsx +++ b/web/app/components/datasets/create/step-one/components/preview-panel.tsx @@ -3,7 +3,7 @@ import type { NotionPage } from '@/models/common' import type { CrawlResultItem } from '@/models/datasets' import { useTranslation } from 'react-i18next' -import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal' +import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal' import FilePreview from '../../file-preview' import NotionPagePreview from '../../notion-page-preview' import WebsitePreview from '../../website/preview' diff --git a/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx index 7daff43a8b..18241f2139 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/__tests__/index.spec.tsx @@ -112,18 +112,6 @@ vi.mock('@/app/components/billing/vector-space-full', () => ({ default: () =>
Vector Space Full
, })) -vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({ - default: ({ show, onClose }: { show: boolean, onClose: () => void }) => ( - show - ? ( -
- -
- ) - : null - ), -})) - vi.mock('@/app/components/datasets/create/step-one/upgrade-card', () => ({ default: () =>
Upgrade Card
, })) diff --git a/web/app/components/datasets/documents/create-from-pipeline/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/index.tsx index 8b8fad5885..799f24fa2a 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/index.tsx @@ -8,7 +8,7 @@ import { useBoolean } from 'ahooks' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' -import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal' +import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useProviderContextSelector } from '@/context/provider-context' import { DatasourceType } from '@/models/pipeline' diff --git a/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx b/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx index 35b62915da..b9d967a692 100644 --- a/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/segment-add/__tests__/index.spec.tsx @@ -14,21 +14,6 @@ vi.mock('@/context/provider-context', () => ({ }), })) -// Mock PlanUpgradeModal -vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({ - default: ({ show, onClose, title, description }: { show: boolean, onClose: () => void, title?: string, description?: string }) => ( - show - ? ( -
- {title} - {description} - -
- ) - : null - ), -})) - describe('SegmentAdd', () => { beforeEach(() => { vi.clearAllMocks() @@ -189,7 +174,7 @@ describe('SegmentAdd', () => { fireEvent.click(screen.getByText(/list\.action\.addButton/i)) - expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument() + expect(screen.getByRole('dialog')).toBeInTheDocument() }) it('should not call showNewSegmentModal for sandbox users', () => { @@ -219,11 +204,11 @@ describe('SegmentAdd', () => { // Show modal fireEvent.click(screen.getByText(/list\.action\.addButton/i)) - expect(screen.getByTestId('plan-upgrade-modal')).toBeInTheDocument() + expect(screen.getByRole('dialog')).toBeInTheDocument() - fireEvent.click(screen.getByTestId('close-modal')) + fireEvent.click(screen.getByRole('button', { name: 'billing.triggerLimitModal.dismiss' })) - expect(screen.queryByTestId('plan-upgrade-modal')).not.toBeInTheDocument() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/datasets/documents/detail/segment-add/index.tsx b/web/app/components/datasets/documents/detail/segment-add/index.tsx index de9735f62f..5ee0a2bcb3 100644 --- a/web/app/components/datasets/documents/detail/segment-add/index.tsx +++ b/web/app/components/datasets/documents/detail/segment-add/index.tsx @@ -11,7 +11,7 @@ import { useBoolean } from 'ahooks' import * as React from 'react' import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal' +import { PlanUpgradeModal } from '@/app/components/billing/plan-upgrade-modal' import { Plan } from '@/app/components/billing/type' import { useProviderContext } from '@/context/provider-context' diff --git a/web/app/components/workflow/nodes/human-input/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/human-input/__tests__/panel.spec.tsx index 143b05afae..6ff61dce3f 100644 --- a/web/app/components/workflow/nodes/human-input/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/__tests__/panel.spec.tsx @@ -30,11 +30,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ }, })) -vi.mock('@/app/components/base/tooltip', () => ({ - __esModule: true, - default: () =>
tooltip
, -})) - vi.mock('@/app/components/base/action-button', () => ({ __esModule: true, default: (props: { diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/email-configure-modal.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/email-configure-modal.spec.tsx index c5b5c680dc..d9875c7539 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/email-configure-modal.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/email-configure-modal.spec.tsx @@ -92,9 +92,9 @@ describe('human-input/delivery-method/email-configure-modal', () => { render( , ) @@ -127,8 +127,8 @@ describe('human-input/delivery-method/email-configure-modal', () => { render( , ) @@ -162,12 +162,12 @@ describe('human-input/delivery-method/email-configure-modal', () => { }) it('should close from both the icon trigger and the cancel button', () => { - const handleClose = vi.fn() + const handleOpenChange = vi.fn() render( , ) @@ -175,6 +175,7 @@ describe('human-input/delivery-method/email-configure-modal', () => { fireEvent.click(screen.getByRole('dialog').querySelector('.absolute') as HTMLDivElement) fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) - expect(handleClose).toHaveBeenCalledTimes(2) + expect(handleOpenChange).toHaveBeenCalledTimes(2) + expect(handleOpenChange).toHaveBeenCalledWith(false) }) }) 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 03bc0f2b79..087440c62d 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,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { DeliveryMethodType } from '../../../types' import DeliveryMethodForm from '../index' @@ -9,11 +9,6 @@ vi.mock('react-i18next', () => ({ useTranslation: () => mockUseTranslation(), })) -vi.mock('@/app/components/base/tooltip', () => ({ - __esModule: true, - default: ({ popupContent }: { popupContent: string }) =>
{popupContent}
, -})) - vi.mock('@/app/components/workflow/hooks', () => ({ useNodesSyncDraft: () => mockUseNodesSyncDraft(), })) @@ -62,15 +57,6 @@ vi.mock('../method-item', () => ({ ), })) -vi.mock('../upgrade-modal', () => ({ - __esModule: true, - default: ({ onClose }: { onClose: () => void }) => ( - - ), -})) - describe('DeliveryMethodForm', () => { const onChange = vi.fn() const mockHandleSyncWorkflowDraft = vi.fn() @@ -132,7 +118,7 @@ describe('DeliveryMethodForm', () => { expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true, true) }) - it('should open and close the upgrade modal', () => { + it('should open and close the upgrade modal', async () => { render( { ) fireEvent.click(screen.getByText('show-upgrade')) - expect(screen.getByText('upgrade-modal')).toBeInTheDocument() + expect(screen.getByRole('dialog')).toBeInTheDocument() - fireEvent.click(screen.getByText('upgrade-modal')) - expect(screen.queryByText('upgrade-modal')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'nodes.humanInput.deliveryMethod.upgradeTipHide' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) }) }) diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/method-item.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/method-item.spec.tsx index 5f11552c70..8ad330e3f2 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/method-item.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/method-item.spec.tsx @@ -6,15 +6,15 @@ import { DeliveryMethodType } from '../../../types' import DeliveryMethodItem from '../method-item' type EmailConfigureModalProps = { - isShow: boolean + open: boolean config?: EmailConfig - onClose: () => void + onOpenChange: (open: boolean) => void onConfirm: (data: EmailConfig) => void } type TestEmailSenderProps = { - isShow: boolean - onClose: () => void + open: boolean + onOpenChange: (open: boolean) => void jumpToEmailConfigModal: () => void } @@ -30,7 +30,7 @@ vi.mock('@/context/app-context', () => ({ vi.mock('../email-configure-modal', () => ({ default: (props: EmailConfigureModalProps) => { mockEmailConfigureModal(props) - return props.isShow + return props.open ? (
- +
) : null @@ -54,11 +54,11 @@ vi.mock('../email-configure-modal', () => ({ vi.mock('../test-email-sender', () => ({ default: (props: TestEmailSenderProps) => { mockTestEmailSender(props) - return props.isShow + return props.open ? (
- +
) : null @@ -140,14 +140,14 @@ describe('human-input/delivery-method/method-item', () => { const row = getMethodRow('webapp') const actionButtons = within(row).getAllByRole('button') - const deleteButtonWrapper = actionButtons[0]!.parentElement as HTMLDivElement + const deleteButton = actionButtons[0]! - fireEvent.mouseEnter(deleteButtonWrapper) + fireEvent.mouseEnter(deleteButton) expect(row)!.toHaveClass('border-state-destructive-border') - fireEvent.mouseLeave(deleteButtonWrapper) + fireEvent.mouseLeave(deleteButton) expect(row).not.toHaveClass('border-state-destructive-border') - fireEvent.click(actionButtons[0]!) + fireEvent.click(deleteButton) expect(handleDelete).toHaveBeenCalledWith(DeliveryMethodType.WebApp) }) diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/method-selector.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/method-selector.spec.tsx index e1008d7457..9cbcd1c189 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/method-selector.spec.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/method-selector.spec.tsx @@ -67,7 +67,7 @@ describe('human-input/delivery-method/method-selector', () => { }) expect(handleShowUpgradeTip).not.toHaveBeenCalled() expect(screen.getByText('workflow.nodes.humanInput.deliveryMethod.contactTip1')).toBeInTheDocument() - expect(screen.getByRole('tooltip')).toHaveTextContent('nodes.humanInput.deliveryMethod.contactTip2') + expect(screen.getByText('nodes.humanInput.deliveryMethod.contactTip2')).toBeInTheDocument() }) it('should disable webapp in trigger mode and show added states without creating duplicates', () => { diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/test-email-sender.spec.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/test-email-sender.spec.tsx new file mode 100644 index 0000000000..7c62b20b8a --- /dev/null +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/__tests__/test-email-sender.spec.tsx @@ -0,0 +1,298 @@ +import type { ReactNode } from 'react' +import type { EmailConfig, FormInputItem } from '../../../types' +import type { App, AppSSO } from '@/types/app' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useStore as useAppStore } from '@/app/components/app/store' +import { HooksStoreContext } from '@/app/components/workflow/hooks-store/provider' +import { createHooksStore } from '@/app/components/workflow/hooks-store/store' +import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/types' +import { AppContext, initialLangGeniusVersionInfo, initialWorkspaceInfo, userProfilePlaceholder } from '@/context/app-context' +import EmailSenderModal from '../test-email-sender' + +type RecordedRequest = { + url: string + method: string + body?: unknown +} + +const createQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, +}) + +const renderWithProviders = (ui: ReactNode) => { + const queryClient = createQueryClient() + const hooksStore = createHooksStore({}) + + return render( + + selector({ + userProfile: { + ...userProfilePlaceholder, + id: 'user-1', + email: 'owner@example.com', + name: 'Owner', + }, + currentWorkspace: { + ...initialWorkspaceInfo, + id: 'workspace-1', + name: 'Product Team', + }, + isCurrentWorkspaceManager: true, + isCurrentWorkspaceOwner: true, + isCurrentWorkspaceEditor: true, + isCurrentWorkspaceDatasetOperator: true, + mutateUserProfile: vi.fn(), + mutateCurrentWorkspace: vi.fn(), + langGeniusVersionInfo: initialLangGeniusVersionInfo, + useSelector: vi.fn(), + isLoadingCurrentWorkspace: false, + isValidatingCurrentWorkspace: false, + }), + isLoadingCurrentWorkspace: false, + isValidatingCurrentWorkspace: false, + }} + > + + {ui} + + + , + ) +} + +const setupFetch = () => { + const requests: RecordedRequest[] = [] + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (resource: RequestInfo | URL, options?: RequestInit) => { + const request = resource instanceof Request ? resource : new Request(resource, options) + const body = request.method === 'GET' ? undefined : await request.clone().json() + requests.push({ + url: request.url, + method: request.method, + body, + }) + + if (request.url.includes('/workspaces/current/members')) { + return new Response(JSON.stringify({ + accounts: [ + { + id: 'member-1', + email: 'member@example.com', + name: 'Member One', + avatar: '', + avatar_url: '', + status: 'active', + role: 'normal', + created_at: '', + last_active_at: '', + last_login_at: '', + }, + ], + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + + return new Response(JSON.stringify({ result: 'success' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) + + return { + fetchSpy, + requests, + } +} + +const createConfig = (overrides: Partial = {}): EmailConfig => ({ + recipients: { + whole_workspace: true, + items: [], + }, + subject: 'Review request', + body: 'Please review {{#start.score#}}', + debug_mode: false, + ...overrides, +}) + +const createFormInput = (overrides: Partial = {}): FormInputItem => ({ + type: InputVarType.textInput, + output_variable_name: 'user_name', + default: { + type: 'variable', + selector: ['start', 'user_name'], + value: '', + }, + ...overrides, +}) + +describe('human-input/delivery-method/test-email-sender', () => { + beforeEach(() => { + vi.clearAllMocks() + useAppStore.setState({ + appDetail: { + id: 'app-1', + name: 'Workflow App', + } as App & Partial, + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should submit generated variable inputs and show the success state', async () => { + const user = userEvent.setup() + const { requests } = setupFetch() + const handleOpenChange = vi.fn() + + renderWithProviders( + , + ) + + const sendButton = screen.getByRole('button', { name: 'workflow.nodes.humanInput.deliveryMethod.emailSender.send' }) + expect(sendButton).toBeDisabled() + + await user.type(screen.getByPlaceholderText('user_name'), 'Ada') + await user.type(screen.getByPlaceholderText('score'), '42') + expect(sendButton).toBeEnabled() + + await user.click(sendButton) + + await waitFor(() => expect(screen.getByText('workflow.nodes.humanInput.deliveryMethod.emailSender.done')).toBeInTheDocument()) + expect(requests).toContainEqual(expect.objectContaining({ + url: 'http://localhost:5001/console/api/apps/app-1/workflows/draft/human-input/nodes/human-node/delivery-test', + method: 'POST', + body: { + delivery_method_id: 'delivery-1', + inputs: { + '#start.user_name#': 'Ada', + '#start.score#': '42', + }, + }, + })) + + await user.click(screen.getByRole('button', { name: 'common.operation.ok' })) + + expect(handleOpenChange).toHaveBeenCalledWith(false) + }) + + it('should render fallback variable inputs and allow cancelling', async () => { + const user = userEvent.setup() + setupFetch() + const handleOpenChange = vi.fn() + + renderWithProviders( + , + ) + + expect(screen.getByPlaceholderText('message')).toBeInTheDocument() + + await user.click(screen.getByText('workflow.nodes.humanInput.deliveryMethod.emailSender.vars')) + + expect(screen.queryByPlaceholderText('message')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + + expect(handleOpenChange).toHaveBeenCalledWith(false) + }) + + it('should show selected recipients with the email configuration tip', () => { + setupFetch() + + renderWithProviders( + , + ) + + expect(screen.getByText('external@example.com')).toBeInTheDocument() + expect(screen.getByText('nodes.humanInput.deliveryMethod.emailSender.tip')).toBeInTheDocument() + }) +}) 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 537fa351e1..0abae16f9a 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,5 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' -import UpgradeModal from '../upgrade-modal' +import { UpgradeModal } from '../upgrade-modal' const mockUseModalContextSelector = vi.hoisted(() => vi.fn()) @@ -32,8 +32,8 @@ describe('human-input/delivery-method/upgrade-modal', () => { render( , ) @@ -41,7 +41,7 @@ describe('human-input/delivery-method/upgrade-modal', () => { expect(screen.getByText('workflow.nodes.humanInput.deliveryMethod.upgradeTipContent')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.humanInput.deliveryMethod.upgradeTipHide' })) - expect(handleClose).toHaveBeenCalledTimes(1) + expect(handleClose).toHaveBeenCalledWith(false) fireEvent.click(screen.getByRole('button', { name: /billing.upgradeBtn.encourageShort/i })) expect(handleShowPricingModal).toHaveBeenCalledTimes(1) diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx index de38564d95..046320ab37 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx @@ -4,15 +4,13 @@ import type { NodeOutPutVar, } from '@/app/components/workflow/types' import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { Switch } from '@langgenius/dify-ui/switch' import { toast } from '@langgenius/dify-ui/toast' -import { RiBugLine, RiCloseLine } from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { RiBugLine } from '@remixicon/react' import { memo, useCallback, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' -import Divider from '@/app/components/base/divider' import Input from '@/app/components/base/input' -import Modal from '@/app/components/base/modal' import { useSelector as useAppContextWithSelector } from '@/context/app-context' import MailBodyInput from './mail-body-input' import Recipient from './recipient' @@ -20,8 +18,8 @@ import Recipient from './recipient' const i18nPrefix = 'nodes.humanInput' type EmailConfigureModalProps = { - isShow: boolean - onClose: () => void + open: boolean + onOpenChange: (open: boolean) => void onConfirm: (data: EmailConfig) => void config?: EmailConfig nodesOutputVars?: NodeOutPutVar[] @@ -29,8 +27,8 @@ type EmailConfigureModalProps = { } const EmailConfigureModal = ({ - isShow, - onClose, + open, + onOpenChange, onConfirm, config, nodesOutputVars = [], @@ -78,89 +76,87 @@ const EmailConfigureModal = ({ }, [checkValidConfig, onConfirm, recipients, subject, body, debugMode]) return ( - -
- -
-
-
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.title`, { ns: 'workflow' })}
-
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.description`, { ns: 'workflow' })}
-
-
-
-
- {t(`${i18nPrefix}.deliveryMethod.emailConfigure.subject`, { ns: 'workflow' })} -
- setSubject(e.target.value)} - placeholder={t(`${i18nPrefix}.deliveryMethod.emailConfigure.subjectPlaceholder`, { ns: 'workflow' })} - /> + + +
+ {t(`${i18nPrefix}.deliveryMethod.emailConfigure.title`, { ns: 'workflow' })} +
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.description`, { ns: 'workflow' })}
-
-
- {t(`${i18nPrefix}.deliveryMethod.emailConfigure.body`, { ns: 'workflow' })} -
- -
-
-
- {t(`${i18nPrefix}.deliveryMethod.emailConfigure.recipient`, { ns: 'workflow' })} -
- -
- -
-
- -
-
-
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.debugMode`, { ns: 'workflow' })}
-
- {email} }} - values={{ email }} - /> -
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.debugModeTip2`, { ns: 'workflow' })}
+
+
+
+ {t(`${i18nPrefix}.deliveryMethod.emailConfigure.subject`, { ns: 'workflow' })}
+ setSubject(e.target.value)} + placeholder={t(`${i18nPrefix}.deliveryMethod.emailConfigure.subjectPlaceholder`, { ns: 'workflow' })} + /> +
+
+
+ {t(`${i18nPrefix}.deliveryMethod.emailConfigure.body`, { ns: 'workflow' })} +
+ +
+
+
+ {t(`${i18nPrefix}.deliveryMethod.emailConfigure.recipient`, { ns: 'workflow' })} +
+ +
+
+
+ +
+
+
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.debugMode`, { ns: 'workflow' })}
+
+ {email} }} + values={{ email }} + /> +
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.debugModeTip2`, { ns: 'workflow' })}
+
+
+ setDebugMode(checked)} + />
- setDebugMode(checked)} - />
-
-
- - -
- +
+ + +
+ + ) } diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/index.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/index.tsx index cdfda74aeb..50c2bf333a 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/index.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/index.tsx @@ -3,14 +3,14 @@ import type { Node, NodeOutPutVar, } from '@/app/components/workflow/types' -import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { produce } from 'immer' import * as React from 'react' import { useTranslation } from 'react-i18next' +import { Infotip } from '@/app/components/base/infotip' import { useNodesSyncDraft } from '@/app/components/workflow/hooks' import MethodItem from './method-item' import MethodSelector from './method-selector' -import UpgradeModal from './upgrade-modal' +import { UpgradeModal } from './upgrade-modal' const i18nPrefix = 'nodes.humanInput' @@ -62,27 +62,15 @@ const DeliveryMethodForm: React.FC = ({ const handleShowUpgradeModal = () => { setShowUpgradeModal(true) } - const handleCloseUpgradeModal = () => { - setShowUpgradeModal(false) - } return (
{t(`${i18nPrefix}.deliveryMethod.title`, { ns: 'workflow' })}
- - - - - )} - /> - - {t(`${i18nPrefix}.deliveryMethod.tooltip`, { ns: 'workflow' })} - - + + {t(`${i18nPrefix}.deliveryMethod.tooltip`, { ns: 'workflow' })} +
{!readonly && (
@@ -115,12 +103,10 @@ const DeliveryMethodForm: React.FC = ({ ))}
)} - {showUpgradeModal && ( - - )} +
) } diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/method-item.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/method-item.tsx index 9cd63e96dd..8abace95f8 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/method-item.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/method-item.tsx @@ -7,6 +7,7 @@ import type { import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { Switch } from '@langgenius/dify-ui/switch' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { RiDeleteBinLine, RiEqualizer2Line, @@ -18,7 +19,6 @@ import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton, { ActionButtonState } from '@/app/components/base/action-button' import Badge from '@/app/components/base/badge/index' -import Tooltip from '@/app/components/base/tooltip' import Indicator from '@/app/components/header/indicator' import { useSelector as useAppContextWithSelector } from '@/context/app-context' import { DeliveryMethodType } from '../../types' @@ -79,6 +79,8 @@ const DeliveryMethodItem: FC = ({ } return t(`${i18nPrefix}.deliveryMethod.emailSender.testSendTip`, { ns: 'workflow' }) }, [method.type, method.config?.debug_mode, t, email]) + const configureLabel = t('common.configure', { ns: 'workflow' }) + const removeLabel = t('operation.remove', { ns: 'common' }) const jumpToEmailConfigModal = useCallback(() => { setShowTestEmailModal(false) @@ -114,47 +116,49 @@ const DeliveryMethodItem: FC = ({
{method.type === DeliveryMethodType.Email && method.config && ( <> - - { - setShowTestEmailModal(true) - }} - > - - + + setShowTestEmailModal(true)} + > + + + )} + /> + {emailSenderTooltipContent} - - setShowEmailModal(true)}> - - - + + setShowEmailModal(true)} + > + + + )} + /> + {configureLabel} )} - -
setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} - > - onDelete(method.type)} - > - - -
+ + setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + onClick={() => onDelete(method.type)} + > + + + )} + /> + {removeLabel}
)} @@ -178,33 +182,29 @@ const DeliveryMethodItem: FC = ({ )}
- {showEmailModal && ( - setShowEmailModal(false)} - onConfirm={(data) => { - handleConfigChange(data) - setShowEmailModal(false) - }} - /> - )} - {showTestEmailModal && ( - setShowTestEmailModal(false)} - jumpToEmailConfigModal={jumpToEmailConfigModal} - /> - )} + { + handleConfigChange(data) + setShowEmailModal(false) + }} + /> + ) } diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/method-selector.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/method-selector.tsx index 16c2345549..9ef75fd639 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/method-selector.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/method-selector.tsx @@ -2,6 +2,11 @@ import type { FC } from 'react' import type { DeliveryMethod } from '../../types' import { cn } from '@langgenius/dify-ui/cn' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@langgenius/dify-ui/popover' import { RiAddLine, RiDiscordFill, @@ -9,17 +14,12 @@ import { RiMailSendFill, RiRobot2Fill, } from '@remixicon/react' -import { memo, useCallback, useMemo, useRef, useState } from 'react' +import { memo, useMemo, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import { v4 as uuid4 } from 'uuid' import ActionButton from '@/app/components/base/action-button' import Badge from '@/app/components/base/badge' import { Slack, Teams } from '@/app/components/base/icons/src/public/other' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' import useWorkflowNodes from '@/app/components/workflow/store/workflow/use-nodes' import { isTriggerWorkflow } from '@/app/components/workflow/utils/workflow-entry' import { IS_CE_EDITION } from '@/config' @@ -40,20 +40,10 @@ const MethodSelector: FC = ({ onShowUpgradeTip, }) => { const { t } = useTranslation() - const [open, doSetOpen] = useState(false) + const [open, setOpen] = useState(false) const humanInputEmailDeliveryEnabled = useProviderContextSelector(s => s.humanInputEmailDeliveryEnabled) - const openRef = useRef(open) const nodes = useWorkflowNodes() - const setOpen = useCallback((v: boolean) => { - doSetOpen(v) - openRef.current = v - }, [doSetOpen]) - - const handleTrigger = useCallback(() => { - setOpen(!openRef.current) - }, [setOpen]) - const webAppDeliveryInfo = useMemo(() => { const isTriggerMode = isTriggerWorkflow(nodes) return { @@ -71,23 +61,25 @@ const MethodSelector: FC = ({ }, [data, humanInputEmailDeliveryEnabled]) return ( - - -
- + -
-
- + )} + /> +
= ({
)} - - + + ) } export default memo(MethodSelector) diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx index 9ad52fa13e..b88ec6cef6 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx @@ -3,16 +3,17 @@ import type { Node, NodeOutPutVar, ValueSelector, + Var, } from '@/app/components/workflow/types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { RiArrowRightSFill, RiCloseLine } from '@remixicon/react' +import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' +import { RiArrowRightSFill } from '@remixicon/react' import { noop, unionBy } from 'es-toolkit/compat' import { memo, useCallback, useMemo, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import { useStore as useAppStore } from '@/app/components/app/store' import Divider from '@/app/components/base/divider' -import Modal from '@/app/components/base/modal' import { getInputVars as doGetInputVars } from '@/app/components/base/prompt-editor/constants' import FormItem from '@/app/components/workflow/nodes/_base/components/before-run-form/form-item' import { @@ -30,11 +31,11 @@ import EmailInput from './recipient/email-input' const i18nPrefix = 'nodes.humanInput' -type EmailConfigureModalProps = { +type EmailSenderModalProps = { nodeId: string deliveryId: string - isShow: boolean - onClose: () => void + open: boolean + onOpenChange: (open: boolean) => void jumpToEmailConfigModal: () => void config?: EmailConfig formContent?: string @@ -48,18 +49,22 @@ const getOriginVar = (valueSelector: string[], list: NodeOutPutVar[]) => { if (!targetVar) return undefined - let curr: any = targetVar.vars + let curr: Var[] | undefined = targetVar.vars for (let i = 1; i < valueSelector.length; i++) { const key = valueSelector[i] const isLast = i === valueSelector.length - 1 + const currentVar: Var | undefined = curr?.find(v => v.variable.replace('conversation.', '') === key) - if (Array.isArray(curr)) - curr = curr.find((v: any) => v.variable.replace('conversation.', '') === key) + if (!currentVar) + return undefined if (isLast) - return curr - else if (curr?.type === VarType.object || curr?.type === VarType.file) - curr = curr.children + return currentVar + + if ((currentVar.type === VarType.object || currentVar.type === VarType.file) && Array.isArray(currentVar.children)) + curr = currentVar.children + else + return undefined } return undefined @@ -68,15 +73,15 @@ const getOriginVar = (valueSelector: string[], list: NodeOutPutVar[]) => { const EmailSenderModal = ({ nodeId, deliveryId, - isShow, - onClose, + open, + onOpenChange, jumpToEmailConfigModal, config, formContent, formInputs, nodesOutputVars = [], availableNodes = [], -}: EmailConfigureModalProps) => { +}: EmailSenderModalProps) => { const { t } = useTranslation() const { userProfile, currentWorkspace } = useAppContext() const appDetail = useAppStore(state => state.appDetail) @@ -104,7 +109,7 @@ const EmailSenderModal = ({ return { label: { nodeType: varInfo?.type, - nodeName: varInfo?.title || availableNodes[0]?.data.title!, // default start node title + nodeName: varInfo?.title || availableNodes[0]?.data.title || '', variable: isSystemVar(item) ? item.join('.') : item[item.length - 1]!, isChatVar: isConversationVar(item), }, @@ -178,194 +183,194 @@ const EmailSenderModal = ({ if (done) { return ( - -
-
{t(`${i18nPrefix}.deliveryMethod.emailSender.done`, { ns: 'workflow' })}
- {debugEnabled && ( -
- }} - values={{ email: userProfile.email }} + +
+ {t(`${i18nPrefix}.deliveryMethod.emailSender.done`, { ns: 'workflow' })} + {debugEnabled && ( +
+ }} + values={{ email: userProfile.email }} + /> +
+ )} + {!debugEnabled && onlyWholeTeam && ( +
+ }} + values={{ team: currentWorkspace.name.replace(/'/g, '’') }} + /> +
+ )} + {!debugEnabled && onlySpecificUsers && ( +
{t(`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone3`, { ns: 'workflow' })}
+ )} + {!debugEnabled && combinedRecipients && ( +
+ }} + values={{ team: currentWorkspace.name.replace(/'/g, '’') }} + /> +
+ )} +
+ {(onlySpecificUsers || combinedRecipients) && !debugEnabled && ( +
+
)} +
+ +
+
+ + ) + } + + return ( + + + +
+ {t(`${i18nPrefix}.deliveryMethod.emailSender.title`, { ns: 'workflow' })} + {debugEnabled && ( + <> +
{t(`${i18nPrefix}.deliveryMethod.emailSender.debugModeTip`, { ns: 'workflow' })}
+
+ }} + values={{ email: userProfile.email }} + /> +
+ + )} {!debugEnabled && onlyWholeTeam && ( -
+
}} + components={{ team: }} values={{ team: currentWorkspace.name.replace(/'/g, '’') }} />
)} {!debugEnabled && onlySpecificUsers && ( -
{t(`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone3`, { ns: 'workflow' })}
+
{t(`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip3`, { ns: 'workflow' })}
)} {!debugEnabled && combinedRecipients && ( -
+
}} + components={{ team: }} values={{ team: currentWorkspace.name.replace(/'/g, '’') }} />
)}
{(onlySpecificUsers || combinedRecipients) && !debugEnabled && ( -
- -
- )} -
- -
- - ) - } - - return ( - -
- -
-
-
{t(`${i18nPrefix}.deliveryMethod.emailSender.title`, { ns: 'workflow' })}
- {debugEnabled && ( <> -
{t(`${i18nPrefix}.deliveryMethod.emailSender.debugModeTip`, { ns: 'workflow' })}
-
+
+ +
+
}} - values={{ email: userProfile.email }} + components={{ + strong: , + }} />
)} - {!debugEnabled && onlyWholeTeam && ( -
- }} - values={{ team: currentWorkspace.name.replace(/'/g, '’') }} - /> -
- )} - {!debugEnabled && onlySpecificUsers && ( -
{t(`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip3`, { ns: 'workflow' })}
- )} - {!debugEnabled && combinedRecipients && ( -
- }} - values={{ team: currentWorkspace.name.replace(/'/g, '’') }} - /> -
- )} -
- {(onlySpecificUsers || combinedRecipients) && !debugEnabled && ( - <> -
- -
-
- , - }} - /> -
- - )} - {/* vars */} - {generatedInputs.length > 0 && ( - <> -
- -
-
-
setCollapsed(!collapsed)}> -
{t(`${i18nPrefix}.deliveryMethod.emailSender.vars`, { ns: 'workflow' })}
- + {/* vars */} + {generatedInputs.length > 0 && ( + <> +
+
-
{t(`${i18nPrefix}.deliveryMethod.emailSender.varsTip`, { ns: 'workflow' })}
- {!collapsed && ( -
- {generatedInputs.map((variable, index) => ( -
- handleValueChange(variable.variable, v)} - /> -
- ))} +
+
setCollapsed(!collapsed)}> +
{t(`${i18nPrefix}.deliveryMethod.emailSender.vars`, { ns: 'workflow' })}
+
- )} -
- - )} -
- - -
- +
{t(`${i18nPrefix}.deliveryMethod.emailSender.varsTip`, { ns: 'workflow' })}
+ {!collapsed && ( +
+ {generatedInputs.map((variable, index) => ( +
+ handleValueChange(variable.variable, v)} + /> +
+ ))} +
+ )} +
+ + )} +
+ + +
+ +
) } 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 060ec2428c..18a6e90796 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,76 +1,60 @@ import { Button } from '@langgenius/dify-ui/button' -import { cn } from '@langgenius/dify-ui/cn' -import { - RiMailSendFill, -} from '@remixicon/react' -import { noop } from 'es-toolkit/compat' +import { RiMailSendFill } from '@remixicon/react' import { useTranslation } from 'react-i18next' import { SparklesSoft } from '@/app/components/base/icons/src/public/common' -import Modal from '@/app/components/base/modal' import PremiumBadge from '@/app/components/base/premium-badge' +import { UpgradeModal as BaseUpgradeModal } from '@/app/components/base/upgrade-modal' import { useModalContextSelector } from '@/context/modal-context' type UpgradeModalProps = { - isShow: boolean - onClose: () => void + open: boolean + onOpenChange: (open: boolean) => void } -const UpgradeModal: React.FC = ({ - isShow, - onClose, -}) => { +export function UpgradeModal({ + open, + onOpenChange, +}: UpgradeModalProps) { const { t } = useTranslation() const setShowPricingModal = useModalContextSelector(s => s.setShowPricingModal) + const handleUpgrade = () => { + setShowPricingModal() + } return ( - -
-
- -
-

- {t('nodes.humanInput.deliveryMethod.upgradeTip', { ns: 'workflow' })} -

-

- {t('nodes.humanInput.deliveryMethod.upgradeTipContent', { ns: 'workflow' })} -

-
-
- - { - setShowPricingModal() - }} - > - -
- - {t('upgradeBtn.encourageShort', { ns: 'billing' })} - -
-
-
-
+ + + + +
+ + {t('upgradeBtn.encourageShort', { ns: 'billing' })} + +
+
+ + )} + /> ) } - -export default UpgradeModal diff --git a/web/app/components/workflow/nodes/human-input/panel.tsx b/web/app/components/workflow/nodes/human-input/panel.tsx index b7b65e7de8..fa0914c098 100644 --- a/web/app/components/workflow/nodes/human-input/panel.tsx +++ b/web/app/components/workflow/nodes/human-input/panel.tsx @@ -18,7 +18,7 @@ import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import Divider from '@/app/components/base/divider' -import Tooltip from '@/app/components/base/tooltip' +import { Infotip } from '@/app/components/base/infotip' import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars' import Split from '@/app/components/workflow/nodes/_base/components/split' import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list' @@ -108,9 +108,9 @@ const Panel: FC> = ({
{t(`${i18nPrefix}.formContent.title`, { ns: 'workflow' })}
- + + {t(`${i18nPrefix}.formContent.tooltip`, { ns: 'workflow' })} +
{!readOnly && (
@@ -164,9 +164,9 @@ const Panel: FC> = ({
{t(`${i18nPrefix}.userActions.title`, { ns: 'workflow' })}
- + + {t(`${i18nPrefix}.userActions.tooltip`, { ns: 'workflow' })} +
{!readOnly && (
diff --git a/web/context/modal-context-provider.tsx b/web/context/modal-context-provider.tsx index fcc37a1030..c51d422aad 100644 --- a/web/context/modal-context-provider.tsx +++ b/web/context/modal-context-provider.tsx @@ -169,13 +169,13 @@ export const ModalContextProvider = ({ showModelModal.onCancelCallback() }, [showModelModal]) - const handleSaveModelModal = useCallback((formValues?: Record) => { + const handleSaveModelModal = useCallback((formValues?: Record) => { if (showModelModal?.onSaveCallback) showModelModal.onSaveCallback(showModelModal.payload, formValues) setShowModelModal(null) }, [showModelModal]) - const handleRemoveModelModal = useCallback((formValues?: Record) => { + const handleRemoveModelModal = useCallback((formValues?: Record) => { if (showModelModal?.onRemoveCallback) showModelModal.onRemoveCallback(showModelModal.payload, formValues) setShowModelModal(null) @@ -369,7 +369,7 @@ export const ModalContextProvider = ({ }} onSave={() => { setShowUpdatePluginModal(null) - showUpdatePluginModal.onSaveCallback?.({} as any) + showUpdatePluginModal.onSaveCallback?.() }} /> ) diff --git a/web/context/modal-context.test.tsx b/web/context/modal-context.test.tsx index ce5efdb8d9..7434130c31 100644 --- a/web/context/modal-context.test.tsx +++ b/web/context/modal-context.test.tsx @@ -1,4 +1,5 @@ -import { act, screen, waitFor } from '@testing-library/react' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import * as React from 'react' import { defaultPlan } from '@/app/components/billing/config' import { Plan } from '@/app/components/billing/type' @@ -27,21 +28,6 @@ vi.mock('@/context/app-context', () => ({ useAppContext: () => mockUseAppContext(), })) -let latestTriggerEventsModalProps: any = null -const triggerEventsLimitModalMock = vi.fn((props: any) => { - latestTriggerEventsModalProps = props - return ( -
- - -
- ) -}) - -vi.mock('@/app/components/billing/trigger-events-limit-modal', () => ({ - default: (props: any) => triggerEventsLimitModalMock(props), -})) - type DefaultPlanShape = typeof defaultPlan type ResetShape = { apiRateLimit: number | null @@ -79,8 +65,6 @@ const renderProvider = () => renderWithNuqs( describe('ModalContextProvider trigger events limit modal', () => { beforeEach(() => { - latestTriggerEventsModalProps = null - triggerEventsLimitModalMock.mockClear() mockUseAppContext.mockReset() mockUseProviderContext.mockReset() window.localStorage.clear() @@ -109,25 +93,20 @@ describe('ModalContextProvider trigger events limit modal', () => { // Note: vitest.setup.ts replaces localStorage with a mock object that has vi.fn() methods // We need to spy on the mock's setItem, not Storage.prototype.setItem const setItemSpy = vi.spyOn(localStorage, 'setItem') + const user = userEvent.setup() renderProvider() - await waitFor(() => expect(screen.getByTestId('trigger-limit-modal'))!.toBeInTheDocument()) - expect(latestTriggerEventsModalProps).toMatchObject({ - usage: 3000, - total: 3000, - resetInDays: 5, - }) + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + expect(screen.getAllByText('3000')).toHaveLength(2) - act(() => { - latestTriggerEventsModalProps.onClose() - }) + await user.click(screen.getByRole('button', { name: 'billing.triggerLimitModal.dismiss' })) - await waitFor(() => expect(screen.queryByTestId('trigger-limit-modal')).not.toBeInTheDocument()) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) await waitFor(() => { expect(setItemSpy.mock.calls.length).toBeGreaterThan(0) }) - const [key, value] = (setItemSpy.mock.calls[0] ?? []) as [any, any] + const [key, value] = (setItemSpy.mock.calls[0] ?? []) as [string, string] expect(key).toContain('trigger-events-limit-dismissed-workspace-1-professional-3000-') expect(value).toBe('1') }) @@ -147,18 +126,16 @@ describe('ModalContextProvider trigger events limit modal', () => { throw new Error('Storage disabled') }) const setItemSpy = vi.spyOn(localStorage, 'setItem') + const user = userEvent.setup() renderProvider() - await waitFor(() => expect(screen.getByTestId('trigger-limit-modal'))!.toBeInTheDocument()) + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) - act(() => { - latestTriggerEventsModalProps.onClose() - }) + await user.click(screen.getByRole('button', { name: 'billing.triggerLimitModal.dismiss' })) - await waitFor(() => expect(screen.queryByTestId('trigger-limit-modal')).not.toBeInTheDocument()) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) expect(setItemSpy).not.toHaveBeenCalled() - await waitFor(() => expect(triggerEventsLimitModalMock).toHaveBeenCalledTimes(1)) }) it('falls back to the in-memory guard when localStorage.setItem fails', async () => { @@ -175,16 +152,37 @@ describe('ModalContextProvider trigger events limit modal', () => { vi.spyOn(localStorage, 'setItem').mockImplementation(() => { throw new Error('Quota exceeded') }) + const user = userEvent.setup() renderProvider() - await waitFor(() => expect(screen.getByTestId('trigger-limit-modal'))!.toBeInTheDocument()) + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) - act(() => { - latestTriggerEventsModalProps.onClose() + await user.click(screen.getByRole('button', { name: 'billing.triggerLimitModal.dismiss' })) + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + }) + + it('closes the trigger events limit modal and opens pricing when upgrading', async () => { + const plan = createPlan({ + type: Plan.professional, + usage: { triggerEvents: 400 }, + total: { triggerEvents: 400 }, + reset: { triggerEvents: 6 }, }) + mockUseProviderContext.mockReturnValue({ + plan, + isFetchedPlan: true, + }) + const user = userEvent.setup() - await waitFor(() => expect(screen.queryByTestId('trigger-limit-modal')).not.toBeInTheDocument()) - await waitFor(() => expect(triggerEventsLimitModalMock).toHaveBeenCalledTimes(1)) + renderProvider() + + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()) + + await user.click(screen.getByText('billing.triggerLimitModal.upgrade')) + + await waitFor(() => expect(screen.getByText('billing.plansCommon.mostPopular')).toBeInTheDocument()) + expect(screen.queryByText('400')).not.toBeInTheDocument() }) }) diff --git a/web/context/modal-context.ts b/web/context/modal-context.ts index d019093955..a8f1597cdb 100644 --- a/web/context/modal-context.ts +++ b/web/context/modal-context.ts @@ -28,8 +28,8 @@ import { createContext, useContext, useContextSelector } from 'use-context-selec export type ModalState = { payload: T onCancelCallback?: () => void - onSaveCallback?: (newPayload?: T, formValues?: Record) => void - onRemoveCallback?: (newPayload?: T, formValues?: Record) => void + onSaveCallback?: (newPayload?: T, formValues?: Record) => void + onRemoveCallback?: (newPayload?: T, formValues?: Record) => void onEditCallback?: (newPayload: T) => void onValidateBeforeSaveCallback?: (newPayload: T) => boolean isEditMode?: boolean