email input

This commit is contained in:
JzoNg 2025-08-07 18:12:50 +08:00
parent fcc8789cc3
commit 7d5f6bc255
4 changed files with 271 additions and 38 deletions

View File

@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<div className='p-1 pt-0'>
<div
className='flex max-h-24 min-h-16 flex-wrap overflow-y-auto rounded-lg border border-transparent bg-components-input-bg-normal p-2'
onClick={setInputFocus}
>
{selectedEmails.map(item => (
<EmailItem
key={item.user_id || item.email}
email={email}
data={item as unknown as Member}
onDelete={onDelete}
/>
))}
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-start'
offset={{
mainAxis: 4,
crossAxis: -40,
}}
>
<PortalToFollowElemTrigger asChild 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>
)
}
export default EmailInput

View File

@ -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 (
<div
className='flex h-6 items-center gap-1 rounded-full border border-components-panel-border-subtle bg-components-badge-white-to-dark p-1 shadow-xs'
onClick={e => e.stopPropagation()}
>
<Avatar avatar={data.avatar_url} size={16} name={data.name || data.email}/>
<div title={data.email} className='system-xs-regular max-w-[500px] truncate text-text-primary'>
{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)}
/>
</div>
)
}
export default EmailItem

View File

@ -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 (
<div className='space-y-1'>
<div className='rounded-[10px] border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs'>
@ -60,6 +83,14 @@ const Recipient = ({
/>
</div>
</div>
<EmailInput
email={userProfile.email}
value={data.items}
list={accounts}
onDelete={handleDelete}
onSelect={handleMemberSelect}
onAdd={handleEmailAdd}
/>
</div>
<div className='flex h-10 items-center gap-2 rounded-[10px] border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pl-2.5 pr-3 shadow-xs'>
<div className='flex h-5 w-5 items-center justify-center rounded-xl bg-components-icon-bg-blue-solid text-[14px]'>

View File

@ -16,9 +16,10 @@ type Props = {
list: Member[]
onSelect: (value: any) => void
email: string
hideSearch?: boolean
}
const MemberList: FC<Props> = ({ searchValue, list, value, onSearchChange, onSelect, email }) => {
const MemberList: FC<Props> = ({ searchValue, list, value, onSearchChange, onSelect, email, hideSearch }) => {
const { t } = useTranslation()
const filteredList = useMemo(() => {
@ -32,46 +33,53 @@ const MemberList: FC<Props> = ({ searchValue, list, value, onSearchChange, onSel
})
}, [list, searchValue])
if (hideSearch && filteredList.length === 0)
return null
return (
<div className='min-w-[320px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm'>
<div className='p-2 pb-1'>
<Input
showLeftIcon
value={searchValue}
onChange={e => onSearchChange(e.target.value)}
/>
</div>
<div className='max-h-[248px] overflow-y-auto p-1'>
{filteredList.map(account => (
<div
key={account.id}
className={cn(
'group flex cursor-pointer items-center gap-2 rounded-lg py-1 pl-2 pr-3 hover:bg-state-base-hover',
value.some((item: { user_id: string }) => 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)
}}
>
<Avatar className={cn(value.some((item: { user_id: string }) => item.user_id === account.id) && 'opacity-50')} avatar={account.avatar_url} size={24} name={account.name} />
<div className={cn('grow', value.some((item: { user_id: string }) => item.user_id === account.id) && 'opacity-50')}>
<div className='system-sm-medium text-text-secondary'>
{account.name}
{account.status === 'pending' && <span className='system-xs-medium ml-1 text-text-warning'>{t('common.members.pending')}</span>}
{email === account.email && <span className='system-xs-regular text-text-tertiary'>{t('common.members.you')}</span>}
{!hideSearch && (
<div className='p-2 pb-1'>
<Input
showLeftIcon
value={searchValue}
onChange={e => onSearchChange(e.target.value)}
/>
</div>
)}
{filteredList.length > 0 && (
<div className='max-h-[248px] overflow-y-auto p-1'>
{filteredList.map(account => (
<div
key={account.id}
className={cn(
'group flex cursor-pointer items-center gap-2 rounded-lg py-1 pl-2 pr-3 hover:bg-state-base-hover',
value.some((item: { user_id: string }) => 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)
}}
>
<Avatar className={cn(value.some((item: { user_id: string }) => item.user_id === account.id) && 'opacity-50')} avatar={account.avatar_url} size={24} name={account.name} />
<div className={cn('grow', value.some((item: { user_id: string }) => item.user_id === account.id) && 'opacity-50')}>
<div className='system-sm-medium text-text-secondary'>
{account.name}
{account.status === 'pending' && <span className='system-xs-medium ml-1 text-text-warning'>{t('common.members.pending')}</span>}
{email === account.email && <span className='system-xs-regular text-text-tertiary'>{t('common.members.you')}</span>}
</div>
<div className='system-xs-regular text-text-tertiary'>{account.email}</div>
</div>
<div className='system-xs-regular text-text-tertiary'>{account.email}</div>
{!value.some((item: { user_id: string }) => item.user_id === account.id) && (
<div className='system-xs-medium hidden text-text-accent group-hover:block'>{t(`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.add`)}</div>
)}
{value.some((item: { user_id: string }) => item.user_id === account.id) && (
<div className='system-xs-regular text-text-tertiary'>{t(`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.added`)}</div>
)}
</div>
{!value.some((item: { user_id: string }) => item.user_id === account.id) && (
<div className='system-xs-medium hidden text-text-accent group-hover:block'>{t(`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.add`)}</div>
)}
{value.some((item: { user_id: string }) => item.user_id === account.id) && (
<div className='system-xs-regular text-text-tertiary'>{t(`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.added`)}</div>
)}
</div>
))}
</div>
))}
</div>
)}
</div>
)
}