mirror of https://github.com/langgenius/dify.git
email input
This commit is contained in:
parent
fcc8789cc3
commit
7d5f6bc255
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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]'>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue