From 7d5f6bc255321ab2268c7d3dc347e3455be6832e Mon Sep 17 00:00:00 2001 From: JzoNg Date: Thu, 7 Aug 2025 18:12:50 +0800 Subject: [PATCH] email input --- .../delivery-method/recipient/email-input.tsx | 154 ++++++++++++++++++ .../delivery-method/recipient/email-item.tsx | 40 +++++ .../delivery-method/recipient/index.tsx | 33 +++- .../delivery-method/recipient/member-list.tsx | 82 +++++----- 4 files changed, 271 insertions(+), 38 deletions(-) create mode 100644 web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-input.tsx create mode 100644 web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-item.tsx diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-input.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-input.tsx new file mode 100644 index 0000000000..1e7cd1ca71 --- /dev/null +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-input.tsx @@ -0,0 +1,154 @@ +import React, { useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { Member } from '@/models/common' +import type { Recipient as RecipientItem } from '../../../types' +import EmailItem from './email-item' +import MemberList from './member-list' +import { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, +} from '@/app/components/base/portal-to-follow-elem' + +const i18nPrefix = 'workflow.nodes.humanInput' + +type Props = { + email: string + value: RecipientItem[] + list: Member[] + onDelete: (recipient: RecipientItem) => void + onSelect: (value: any) => void + onAdd: (email: string) => void +} + +const EmailInput = ({ + email, + value, + list, + onDelete, + onSelect, + onAdd, +}: Props) => { + const { t } = useTranslation() + const inputRef = useRef(null) + const [isFocus, setIsFocus] = useState(false) + const [open, setOpen] = useState(false) + const [searchKey, setSearchKey] = useState('') + + const selectedEmails = useMemo(() => { + return value.map((item) => { + const member = list.find(account => account.id === item.user_id) + return member ? { ...item, email: member.email, name: member.name } : item + }) + }, [list, value]) + + const placeholder = useMemo(() => { + return (selectedEmails.length === 0 || isFocus) + ? t(`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.placeholder`) + : '' + }, [selectedEmails, t, isFocus]) + + const setInputFocus = () => { + setIsFocus(true) + inputRef.current?.focus() + } + + const handleValueChange = (e: React.ChangeEvent) => { + setSearchKey(e.target.value) + if (e.target.value.trim() === '') { + setOpen(false) + return + } + setOpen(true) + } + + const handleSelect = (value: any) => { + setSearchKey('') + setOpen(false) + onSelect(value) + setInputFocus() + } + + const checkEmailValid = (email: string) => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return emailRegex.test(email) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === 'Tab' || e.key === ' ' || e.key === ',') { + e.preventDefault() + const emailAddress = searchKey.trim() + if (!checkEmailValid(emailAddress)) + return + if (value.some(item => item.email === emailAddress)) + return + if (list.some(item => item.email === emailAddress)) + onSelect(emailAddress) + else + onAdd(emailAddress) + setSearchKey('') + setOpen(false) + } + else if (e.key === 'Backspace') { + e.preventDefault() + if (searchKey === '' && value.length > 0) { + onDelete(value[value.length - 1]) + setSearchKey('') + setOpen(false) + } + } + } + + return ( +
+
+ {selectedEmails.map(item => ( + + ))} + + + setIsFocus(true)} + onBlur={() => setIsFocus(false)} + value={searchKey} + onChange={handleValueChange} + onKeyDown={handleKeyDown} + /> + + + + + +
+
+ ) +} + +export default EmailInput diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-item.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-item.tsx new file mode 100644 index 0000000000..8a0acfc70a --- /dev/null +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-item.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { RiCloseCircleFill } from '@remixicon/react' +import type { Recipient as RecipientItem } from '../../../types' +import type { Member } from '@/models/common' +import Avatar from '@/app/components/base/avatar' + +type Props = { + email: string + data: Member + onDelete: (recipient: RecipientItem) => void + +} + +const EmailItem = ({ + email, + data, + onDelete, +}: Props) => { + const { t } = useTranslation() + + return ( +
e.stopPropagation()} + > + +
+ {email === data.email ? data.name : data.email} + {email === data.email && {t('common.members.you')}} +
+ onDelete(data as unknown as RecipientItem)} + /> +
+ ) +} + +export default EmailItem diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/index.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/index.tsx index e54b8eaf34..9074aed1a6 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/index.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/index.tsx @@ -5,8 +5,9 @@ import { RiGroupLine } from '@remixicon/react' import { useAppContext } from '@/context/app-context' import Switch from '@/app/components/base/switch' import MemberSelector from './member-selector' +import EmailInput from './email-input' import { fetchMembers } from '@/service/common' -import type { RecipientData } from '../../../types' +import type { RecipientData, Recipient as RecipientItem } from '../../../types' import cn from '@/utils/classnames' import { produce } from 'immer' @@ -43,6 +44,28 @@ const Recipient = ({ ) } + const handleEmailAdd = (email: string) => { + onChange( + produce(data, (draft) => { + draft.items.push({ + type: 'external', + email, + }) + }), + ) + } + + const handleDelete = (recipient: RecipientItem) => { + onChange( + produce(data, (draft) => { + if (recipient.type === 'member') + draft.items = draft.items.filter(item => item.user_id !== recipient.user_id) + else if (recipient.type === 'external') + draft.items = draft.items.filter(item => item.email !== recipient.email) + }), + ) + } + return (
@@ -60,6 +83,14 @@ const Recipient = ({ />
+
diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-list.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-list.tsx index ccd24be37c..53dfab53f5 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-list.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-list.tsx @@ -16,9 +16,10 @@ type Props = { list: Member[] onSelect: (value: any) => void email: string + hideSearch?: boolean } -const MemberList: FC = ({ searchValue, list, value, onSearchChange, onSelect, email }) => { +const MemberList: FC = ({ searchValue, list, value, onSearchChange, onSelect, email, hideSearch }) => { const { t } = useTranslation() const filteredList = useMemo(() => { @@ -32,46 +33,53 @@ const MemberList: FC = ({ searchValue, list, value, onSearchChange, onSel }) }, [list, searchValue]) + if (hideSearch && filteredList.length === 0) + return null + return (
-
- onSearchChange(e.target.value)} - /> -
-
- {filteredList.map(account => ( -
item.user_id === account.id) && 'bg-transparent hover:bg-transparent', - )} - onClick={() => { - if (value.some((item: { user_id: string }) => item.user_id === account.id)) return - onSelect(account.id) - }} - > - item.user_id === account.id) && 'opacity-50')} avatar={account.avatar_url} size={24} name={account.name} /> -
item.user_id === account.id) && 'opacity-50')}> -
- {account.name} - {account.status === 'pending' && {t('common.members.pending')}} - {email === account.email && {t('common.members.you')}} + {!hideSearch && ( +
+ onSearchChange(e.target.value)} + /> +
+ )} + {filteredList.length > 0 && ( +
+ {filteredList.map(account => ( +
item.user_id === account.id) && 'bg-transparent hover:bg-transparent', + )} + onClick={() => { + if (value.some((item: { user_id: string }) => item.user_id === account.id)) return + onSelect(account.id) + }} + > + item.user_id === account.id) && 'opacity-50')} avatar={account.avatar_url} size={24} name={account.name} /> +
item.user_id === account.id) && 'opacity-50')}> +
+ {account.name} + {account.status === 'pending' && {t('common.members.pending')}} + {email === account.email && {t('common.members.you')}} +
+
{account.email}
-
{account.email}
+ {!value.some((item: { user_id: string }) => item.user_id === account.id) && ( +
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.add`)}
+ )} + {value.some((item: { user_id: string }) => item.user_id === account.id) && ( +
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.added`)}
+ )}
- {!value.some((item: { user_id: string }) => item.user_id === account.id) && ( -
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.add`)}
- )} - {value.some((item: { user_id: string }) => item.user_id === account.id) && ( -
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.added`)}
- )} -
- ))} -
+ ))} +
+ )}
) }