email sender modal

This commit is contained in:
JzoNg 2025-09-03 18:38:23 +08:00
parent 527736b8e4
commit 463ea14d44
7 changed files with 336 additions and 41 deletions

View File

@ -83,7 +83,7 @@ const EmailConfigureModal = ({
body,
debug: debugMode,
})
}, [subject, body, onConfirm])
}, [recipients, subject, body, debugMode, onConfirm])
return (
<Modal

View File

@ -20,6 +20,7 @@ import type {
NodeOutPutVar,
} from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
import TestEmailSender from './test-email-sender'
const i18nPrefix = 'workflow.nodes.humanInput'
@ -41,6 +42,7 @@ const DeliveryMethodItem: React.FC<Props> = ({
const { t } = useTranslation()
const [isHovering, setIsHovering] = React.useState(false)
const [showEmailModal, setShowEmailModal] = React.useState(false)
const [showTestEmailModal, setShowTestEmailModal] = React.useState(false)
const handleEnableStatusChange = (enabled: boolean) => {
onChange({
@ -73,13 +75,13 @@ const DeliveryMethodItem: React.FC<Props> = ({
</div>
)}
<div className='system-xs-medium capitalize text-text-secondary'>{method.type}</div>
{method.type === DeliveryMethodType.Email && method.config?.debug && <Badge size='s'>DEBUG</Badge>}
{method.type === DeliveryMethodType.Email && method.config?.debug && <Badge size='s' className='!px-1 !py-0.5'>DEBUG</Badge>}
</div>
<div className='flex items-center gap-1'>
<div className='hidden items-end gap-1 group-hover:flex'>
{method.type === DeliveryMethodType.Email && method.config && (
<>
<ActionButton onClick={() => setShowEmailModal(true)}>
<ActionButton onClick={() => setShowTestEmailModal(true)}>
<RiSendPlane2Line className='h-4 w-4' />
</ActionButton>
<ActionButton onClick={() => setShowEmailModal(true)}>
@ -130,6 +132,19 @@ const DeliveryMethodItem: React.FC<Props> = ({
}}
/>
)}
{showTestEmailModal && (
<TestEmailSender
isShow={showTestEmailModal}
config={method.config}
nodesOutputVars={nodesOutputVars}
availableNodes={availableNodes}
onClose={() => setShowTestEmailModal(false)}
onConfirm={(data) => {
handleConfigChange(data)
setShowTestEmailModal(false)
}}
/>
)}
</>
)
}

View File

@ -20,6 +20,7 @@ type Props = {
onDelete: (recipient: RecipientItem) => void
onSelect: (value: any) => void
onAdd: (email: string) => void
disabled?: boolean
}
const EmailInput = ({
@ -29,6 +30,7 @@ const EmailInput = ({
onDelete,
onSelect,
onAdd,
disabled = false,
}: Props) => {
const { t } = useTranslation()
const inputRef = useRef<HTMLInputElement>(null)
@ -50,6 +52,7 @@ const EmailInput = ({
}, [selectedEmails, t, isFocus])
const setInputFocus = () => {
if (disabled) return
setIsFocus(true)
const input = inputRef.current?.children[0] as HTMLInputElement
input?.focus()
@ -104,7 +107,11 @@ const EmailInput = ({
return (
<div className='p-1 pt-0'>
<div
className={cn('flex max-h-24 min-h-16 flex-wrap overflow-y-auto rounded-lg border border-transparent bg-components-input-bg-normal p-2 hover:border-components-input-border-hover hover:bg-components-input-bg-hover', isFocus && 'border-components-input-border-active bg-components-input-bg-active shadow-xs')}
className={cn(
'flex max-h-24 min-h-16 flex-wrap overflow-y-auto rounded-lg border border-transparent bg-components-input-bg-normal p-2',
isFocus && 'border-components-input-border-active bg-components-input-bg-active shadow-xs',
!disabled && 'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
)}
onClick={setInputFocus}
>
{selectedEmails.map(item => (
@ -113,41 +120,44 @@ const EmailInput = ({
email={email}
data={item as unknown as Member}
onDelete={onDelete}
disabled={disabled}
/>
))}
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-start'
offset={{
mainAxis: 4,
crossAxis: -40,
}}
>
<PortalToFollowElemTrigger className='block h-6 min-w-[166px]'>
<input
ref={inputRef}
className='system-sm-regular h-6 min-w-[166px] appearance-none bg-transparent p-1 text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder'
placeholder={placeholder}
onFocus={() => setIsFocus(true)}
onBlur={() => setIsFocus(false)}
value={searchKey}
onChange={handleValueChange}
onKeyDown={handleKeyDown}
/>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<MemberList
searchValue={searchKey}
list={list}
value={value}
onSearchChange={setSearchKey}
onSelect={handleSelect}
email={email}
hideSearch
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
{!disabled && (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-start'
offset={{
mainAxis: 4,
crossAxis: -40,
}}
>
<PortalToFollowElemTrigger className='block h-6 min-w-[166px]'>
<input
ref={inputRef}
className='system-sm-regular h-6 min-w-[166px] appearance-none bg-transparent p-1 text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder'
placeholder={placeholder}
onFocus={() => setIsFocus(true)}
onBlur={() => setIsFocus(false)}
value={searchKey}
onChange={handleValueChange}
onKeyDown={handleKeyDown}
/>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<MemberList
searchValue={searchKey}
list={list}
value={value}
onSearchChange={setSearchKey}
onSelect={handleSelect}
email={email}
hideSearch
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
)}
</div>
</div>
)

View File

@ -8,6 +8,7 @@ import Avatar from '@/app/components/base/avatar'
type Props = {
email: string
data: Member
disabled?: boolean
onDelete: (recipient: RecipientItem) => void
}
@ -16,6 +17,7 @@ const EmailItem = ({
email,
data,
onDelete,
disabled = false,
}: Props) => {
const { t } = useTranslation()
@ -29,10 +31,12 @@ const EmailItem = ({
{email === data.email ? data.name : data.email}
{email === data.email && <span className='system-xs-regular text-text-tertiary'>{t('common.members.you')}</span>}
</div>
<RiCloseCircleFill
className='h-4 w-4 cursor-pointer text-text-quaternary hover:text-text-tertiary'
onClick={() => onDelete(data as unknown as RecipientItem)}
/>
{!disabled && (
<RiCloseCircleFill
className='h-4 w-4 cursor-pointer text-text-quaternary hover:text-text-tertiary'
onClick={() => onDelete(data as unknown as RecipientItem)}
/>
)}
</div>
)
}

View File

@ -0,0 +1,230 @@
import { memo, useCallback, useState } from 'react'
import useSWR from 'swr'
import { Trans, useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import { RiArrowRightSFill, RiCloseLine } from '@remixicon/react'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import EmailInput from './recipient/email-input'
import type { EmailConfig } from '../../types'
import type {
Node,
NodeOutPutVar,
} from '@/app/components/workflow/types'
import { fetchMembers } from '@/service/common'
import { noop } from 'lodash-es'
import cn from '@/utils/classnames'
const i18nPrefix = 'workflow.nodes.humanInput'
type EmailConfigureModalProps = {
isShow: boolean
onClose: () => void
onConfirm: (data: any) => void
config?: EmailConfig
nodesOutputVars?: NodeOutPutVar[]
availableNodes?: Node[]
}
const EmailSenderModal = ({
isShow,
onClose,
onConfirm,
config,
nodesOutputVars = [],
availableNodes = [],
}: EmailConfigureModalProps) => {
const { t } = useTranslation()
const { userProfile, currentWorkspace } = useAppContext()
const debugEnabled = !!config?.debug
const onlyWholeTeam = config?.recipients?.whole_workspace && (!config?.recipients?.items || config?.recipients?.items.length === 0)
const onlySpecificUsers = !config?.recipients?.whole_workspace && config?.recipients?.items && config?.recipients?.items.length > 0
const combinedRecipients = config?.recipients?.whole_workspace && config?.recipients?.items && config?.recipients?.items.length > 0
const { data: members } = useSWR(
{
url: '/workspaces/current/members',
params: {},
},
fetchMembers,
)
const accounts = members?.accounts || []
const [collapsed, setCollapsed] = useState(true)
const [done, setDone] = useState(false)
const handleConfirm = useCallback(() => {
// TODO send api
setDone(true)
}, [onConfirm])
if (done) {
return (
<Modal
isShow={isShow}
onClose={noop}
className='relative !max-w-[480px] !p-0'
>
<div className='space-y-2 p-6 pb-3'>
<div className='title-2xl-semi-bold text-text-primary'>{t(`${i18nPrefix}.deliveryMethod.emailSender.done`)}</div>
{debugEnabled && (
<div className='system-md-regular text-text-secondary'>
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.debugDone`}
components={{ email: <span className='system-md-semibold text-text-secondary'></span> }}
values={{ email: userProfile.email }}
/>
</div>
)}
{!debugEnabled && onlyWholeTeam && (
<div className='system-md-regular text-text-secondary'>
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone2`}
components={{ team: <span className='system-md-medium text-text-secondary'></span> }}
values={{ team: currentWorkspace.name.replace(/'/g, '') }}
/>
</div>
)}
{!debugEnabled && onlySpecificUsers && (
<div className='system-md-regular text-text-secondary'>{t(`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone3`)}</div>
)}
{!debugEnabled && combinedRecipients && (
<div className='system-md-regular text-text-secondary'>
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone1`}
components={{ team: <span className='system-md-medium text-text-secondary'></span> }}
values={{ team: currentWorkspace.name.replace(/'/g, '') }}
/>
</div>
)}
</div>
{(onlySpecificUsers || combinedRecipients) && (
<div className='px-5'>
<EmailInput
disabled
email={userProfile.email}
value={config?.recipients?.items}
list={accounts}
onDelete={noop}
onSelect={noop}
onAdd={noop}
/>
</div>
)}
<div className='flex flex-row-reverse gap-2 p-6 pt-5'>
<Button
variant='primary'
className='w-[72px]'
onClick={onClose}
>
{t('common.operation.ok')}
</Button>
</div>
</Modal>
)
}
return (
<Modal
isShow={isShow}
onClose={noop}
className='relative !max-w-[480px] !p-0'
>
<div className='absolute right-5 top-5 cursor-pointer p-1.5' onClick={onClose}>
<RiCloseLine className='h-5 w-5 text-text-tertiary' />
</div>
<div className='space-y-1 p-6 pb-3'>
<div className='title-2xl-semi-bold text-text-primary'>{t(`${i18nPrefix}.deliveryMethod.emailSender.title`)}</div>
{debugEnabled && (
<>
<div className='system-sm-regular text-text-secondary'>{t(`${i18nPrefix}.deliveryMethod.emailSender.debugModeTip`)}</div>
<div className='system-sm-regular text-text-secondary'>
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.debugModeTip2`}
components={{ email: <span className='system-sm-semibold text-text-primary'></span> }}
values={{ email: userProfile.email }}
/>
</div>
</>
)}
{!debugEnabled && onlyWholeTeam && (
<div className='system-sm-regular text-text-secondary'>
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip2`}
components={{ team: <span className='system-sm-semibold text-text-primary'></span> }}
values={{ team: currentWorkspace.name.replace(/'/g, '') }}
/>
</div>
)}
{!debugEnabled && onlySpecificUsers && (
<div className='system-sm-regular text-text-secondary'>{t(`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip3`)}</div>
)}
{!debugEnabled && combinedRecipients && (
<div className='system-sm-regular text-text-secondary'>
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip1`}
components={{ team: <span className='system-sm-semibold text-text-primary'></span> }}
values={{ team: currentWorkspace.name.replace(/'/g, '') }}
/>
</div>
)}
</div>
{(onlySpecificUsers || combinedRecipients) && (
<>
<div className='px-5'>
<EmailInput
disabled
email={userProfile.email}
value={config?.recipients?.items}
list={accounts}
onDelete={noop}
onSelect={noop}
onAdd={noop}
/>
</div>
<div className='system-xs-regular px-6 pt-1 text-text-tertiary'>
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.tip`}
components={{ strong: <span className='system-xs-regular text-text-accent'></span> }}
/>
</div>
</>
)}
{/* vars */}
<div className='px-6'>
<Divider className='!mb-2 !mt-4 !h-px !w-12 bg-divider-regular'/>
</div>
<div className='px-6 py-2'>
<div className='group flex h-6 cursor-pointer items-center' onClick={() => setCollapsed(!collapsed)}>
<div className='system-sm-semibold-uppercase mr-1 text-text-secondary'>{t(`${i18nPrefix}.deliveryMethod.emailSender.vars`)}</div>
<div className='system-xs-regular text-text-tertiary'>{t(`${i18nPrefix}.deliveryMethod.emailSender.optional`)}</div>
<RiArrowRightSFill className={cn('h-4 w-4 text-text-quaternary group-hover:text-text-primary', !collapsed && 'rotate-90')} />
</div>
<div className='system-xs-regular text-text-tertiary'>{t(`${i18nPrefix}.deliveryMethod.emailSender.varsTip`)}</div>
{!collapsed && (
<div className='mt-3'>
{/* form TODO */}
</div>
)}
</div>
<div className='flex flex-row-reverse gap-2 p-6 pt-5'>
<Button
variant='primary'
onClick={handleConfirm}
>
{t(`${i18nPrefix}.deliveryMethod.emailSender.send`)}
</Button>
<Button
className='w-[72px]'
onClick={onClose}
>
{t('common.operation.cancel')}
</Button>
</div>
</Modal>
)
}
export default memo(EmailSenderModal)

View File

@ -963,6 +963,24 @@ const translation = {
debugModeTip1: 'During debug mode, emails will only be sent to your account email <email>{{email}}</email>.',
debugModeTip2: 'The production environment is not affected.',
},
emailSender: {
title: 'Test Email Sender',
debugModeTip: 'Debug mode is enabled.',
debugModeTip2: 'Email will be sent to <email>{{email}}</email>.',
wholeTeamTip1: 'Email will be sent to <team>{{team}}</team> members and the following email addresses:',
wholeTeamTip2: 'Email will be sent to <team>{{team}}</team> members.',
wholeTeamTip3: 'Email will be sent to the following email addresses:',
tip: 'It is recommended to <strong>enable Debug Mode</strong> for testing email delivery.',
vars: 'Variables in Form Content',
optional: '(optional)',
varsTip: 'Fill in form variables to emulate what recipients actually see.',
send: 'Send Email',
done: 'Email Sent',
debugDone: 'A test email has been sent to <email>{{email}}</email>. Please check your inbox.',
wholeTeamDone1: 'Email will be sent to <team>{{team}}</team> members and the following email addresses:',
wholeTeamDone2: 'Email will be sent to <team>{{team}}</team> members.',
wholeTeamDone3: 'Email will be sent to the following email addresses:',
},
},
formContent: {
title: 'Form Content',

View File

@ -963,6 +963,24 @@ const translation = {
debugModeTip1: '在调试模式下,电子邮件将仅发送到您的帐户电子邮件 <email>{{email}}</email>。',
debugModeTip2: '生产环境不受影响。',
},
emailSender: {
title: '测试邮件发送器',
debugModeTip: '调试模式已启用。',
debugModeTip2: '邮件将发送到 <email>{{email}}</email>。',
wholeTeamTip1: '邮件将发送给 <team>{{team}}</team> 成员和以下邮件地址:',
wholeTeamTip2: '邮件将发送给 <team>{{team}}</team> 成员。',
wholeTeamTip3: '邮件将发送到以下邮件地址:',
tip: '建议为测试邮件发送启用 <strong>调试模式</strong>。',
vars: '表单内容中的变量',
optional: '(可选)',
varsTip: '填写表单变量以模拟收件人实际看到的内容。',
send: '发送邮件',
done: '邮件已发送',
debugDone: '测试邮件已发送到 <email>{{email}}</email>。请检查您的收件箱。',
wholeTeamDone1: '邮件将发送给 <team>{{team}}</team> 成员和以下邮件地址:',
wholeTeamDone2: '邮件将发送给 <team>{{team}}</team> 成员。',
wholeTeamDone3: '邮件将发送到以下邮件地址:',
},
},
formContent: {
title: '表单内容',