Enhance Email Messaging Components with Recipients Management and UI Improvements

- Introduced ObjectChildCards component to manage recipients in the NewEmailMessage and EmailMessageInfo components, allowing for better organization and display of recipient information.
- Updated EmailMessageInfo to include a collapsible section for recipients, enhancing user experience by providing clear visibility of recipient details.
- Refactored email recipient handling in NewEmailMessage to utilize candidatesToRecipients for improved recipient management and validation.
- Enhanced EmailSendButton to accommodate the new recipients structure, ensuring proper data flow and functionality.
- Improved utility functions for email recipient resolution, streamlining the process of fetching and managing recipient data.
This commit is contained in:
Tom Butcher 2026-09-15 01:56:51 +01:00
parent c9751dd5ef
commit da2881d505
8 changed files with 672 additions and 238 deletions

View File

@ -20,7 +20,8 @@ import { parser as ejsParser } from './ejs.parser.js'
import { import {
fcTemplateElements, fcTemplateElements,
fcTemplateAttributes, fcTemplateAttributes,
getFcTemplateHelpers getFcTemplateHelpers,
placeholderCompletionSource
} from './schema.js' } from './schema.js'
import { normalizeTypedScope } from '../javascriptLang/typedScope.js' import { normalizeTypedScope } from '../javascriptLang/typedScope.js'
@ -140,6 +141,7 @@ export function fcTemplateLang(options = {}) {
} = options } = options
const jsScope = buildJsScope(autoCompleteObject, templateType) const jsScope = buildJsScope(autoCompleteObject, templateType)
const completeResources = resourceCompletionSource(resourceNames) const completeResources = resourceCompletionSource(resourceNames)
const completePlaceholders = placeholderCompletionSource()
return new LanguageSupport(fcTemplateLanguage, [ return new LanguageSupport(fcTemplateLanguage, [
xmlLanguage.data.of({ xmlLanguage.data.of({
@ -149,6 +151,8 @@ export function fcTemplateLang(options = {}) {
) )
}), }),
xmlLanguage.data.of({ autocomplete: completeResources }), xmlLanguage.data.of({ autocomplete: completeResources }),
xmlLanguage.data.of({ autocomplete: completePlaceholders }),
fcTemplateLanguage.data.of({ autocomplete: completePlaceholders }),
javascriptLanguage.data.of({ autocomplete: completeResources }), javascriptLanguage.data.of({ autocomplete: completeResources }),
autoCloseTags, autoCloseTags,
...javascriptCompletionSupport(jsScope) ...javascriptCompletionSupport(jsScope)

View File

@ -8,8 +8,10 @@ import useCollapseState from '../../hooks/useCollapseState.jsx'
import NotesPanel from '../../common/NotesPanel.jsx' import NotesPanel from '../../common/NotesPanel.jsx'
import InfoCollapse from '../../common/InfoCollapse.jsx' import InfoCollapse from '../../common/InfoCollapse.jsx'
import ObjectInfo from '../../common/ObjectInfo.jsx' import ObjectInfo from '../../common/ObjectInfo.jsx'
import ObjectProperty from '../../common/ObjectProperty.jsx'
import ViewButton from '../../common/ViewButton.jsx' import ViewButton from '../../common/ViewButton.jsx'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx' import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import EmailMessageIcon from '../../../Icons/EmailMessageIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx' import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx' import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import ObjectForm from '../../common/ObjectForm.jsx' import ObjectForm from '../../common/ObjectForm.jsx'
@ -21,7 +23,7 @@ import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx' import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import InfoActionButtons from '../../common/InfoActionButtons.jsx' import InfoActionButtons from '../../common/InfoActionButtons.jsx'
import UserNotifierToggle from '../../common/UserNotifierToggle.jsx' import UserNotifierToggle from '../../common/UserNotifierToggle.jsx'
import { getModelByName } from '../../../../database/ObjectModels.js' import { getModelByName, getModelProperty } from '../../../../database/ObjectModels.js'
import ScrollBox from '../../common/ScrollBox.jsx' import ScrollBox from '../../common/ScrollBox.jsx'
import TemplatePreview from '../../common/TemplatePreview.jsx' import TemplatePreview from '../../common/TemplatePreview.jsx'
@ -38,6 +40,7 @@ const EmailMessageInfo = () => {
'EmailMessageInfo', 'EmailMessageInfo',
{ {
info: true, info: true,
recipients: true,
notes: true, notes: true,
auditLogs: false auditLogs: false
} }
@ -87,6 +90,7 @@ const EmailMessageInfo = () => {
disabled={objectFormState.loading} disabled={objectFormState.loading}
items={[ items={[
{ key: 'info', label: 'Email Message Information' }, { key: 'info', label: 'Email Message Information' },
{ key: 'recipients', label: 'Recipients' },
{ key: 'notes', label: 'Notes' }, { key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' } { key: 'auditLogs', label: 'Audit Logs' }
]} ]}
@ -141,10 +145,28 @@ const EmailMessageInfo = () => {
objectData={objectData} objectData={objectData}
labelWidth='190px' labelWidth='190px'
visibleProperties={{ visibleProperties={{
content: false content: false,
recipients: false
}} }}
/> />
</InfoCollapse> </InfoCollapse>
<InfoCollapse
title='Recipients'
icon={<EmailMessageIcon />}
active={collapseState.recipients}
onToggle={(expanded) =>
updateCollapseState('recipients', expanded)
}
collapseKey='recipients'
>
<ObjectProperty
{...getModelProperty('emailMessage', 'recipients')}
isEditing={isEditing}
objectData={objectData}
loading={loading}
size='medium'
/>
</InfoCollapse>
<InfoCollapse <InfoCollapse
title='Email Message Content' title='Email Message Content'
icon={<InfoCircleIcon />} icon={<InfoCircleIcon />}

View File

@ -7,23 +7,21 @@ import {
useRef, useRef,
useState useState
} from 'react' } from 'react'
import { Modal } from 'antd' import { Form, Modal } from 'antd'
import ObjectInfo from '../../common/ObjectInfo' import ObjectInfo from '../../common/ObjectInfo'
import ObjectChildCards from '../../common/ObjectChildCards'
import NewObjectForm from '../../common/NewObjectForm' import NewObjectForm from '../../common/NewObjectForm'
import WizardView from '../../common/WizardView' import WizardView from '../../common/WizardView'
import TemplatePreview from '../../common/TemplatePreview' import TemplatePreview from '../../common/TemplatePreview'
import ProgressDisplay from '../../common/ProgressDisplay' import ProgressDisplay from '../../common/ProgressDisplay'
import { ApiServerContext } from '../../context/ApiServerContext' import { ApiServerContext } from '../../context/ApiServerContext'
import { resolveEmailRecipientsForObjects } from '../../utils/emailRecipients' import {
candidatesToRecipients,
getReferenceId,
resolveEmailRecipientsForObjects
} from '../../utils/emailRecipients'
import { toTemplateObjectData } from '../../utils/templateObjectData' import { toTemplateObjectData } from '../../utils/templateObjectData'
const progressByState = {
queued: 10,
rendering: 35,
sending: 70,
sent: 100,
error: 100
}
const EMPTY_RECIPIENT_CANDIDATES = [] const EMPTY_RECIPIENT_CANDIDATES = []
const EMPTY_DEFAULT_VALUES = {} const EMPTY_DEFAULT_VALUES = {}
@ -40,20 +38,49 @@ const EmailWizardContent = ({
}) => { }) => {
const { fetchObject } = useContext(ApiServerContext) const { fetchObject } = useContext(ApiServerContext)
const [candidates, setCandidates] = useState(defaultCandidates) const [candidates, setCandidates] = useState(defaultCandidates)
const [recipientIndex, setRecipientIndex] = useState(0)
const recipients = Array.isArray(objectData?.recipients)
? objectData.recipients
: []
const currentRecipient = recipients[recipientIndex] || null
const previewObject = useMemo(() => {
const selected = currentRecipient?.object
const selectedId = getReferenceId(selected)
const objects = Array.isArray(objectData?.objects) ? objectData.objects : []
if (selectedId) {
const match = objects.find((item) => getReferenceId(item) === selectedId)
if (match && typeof match === 'object' && !Array.isArray(match)) {
return match
}
}
if (selected && typeof selected === 'object' && !Array.isArray(selected)) {
return selected
}
return null
}, [currentRecipient?.object, objectData?.objects])
const relatedObjects = Array.isArray(objectData?.objects)
? objectData.objects
: objectData?.object
? [objectData.object]
: []
const objectIdsKey = relatedObjects
.map((item) => getReferenceId(item))
.filter(Boolean)
.join('|')
const relatedObjectsRef = useRef(relatedObjects)
relatedObjectsRef.current = relatedObjects
useEffect(() => { useEffect(() => {
if (defaultCandidates.length) { const objects = relatedObjectsRef.current
setCandidates(defaultCandidates) if (!objects.length) {
return setCandidates([])
return undefined
} }
let cancelled = false let cancelled = false
resolveEmailRecipientsForObjects({ resolveEmailRecipientsForObjects({
type: objectData?.objectType, type: objectData?.objectType,
objects: Array.isArray(objectData?.objects) objects,
? objectData.objects
: objectData?.object
? [objectData.object]
: [],
fetchObject fetchObject
}).then((result) => { }).then((result) => {
if (!cancelled) setCandidates(result) if (!cancelled) setCandidates(result)
@ -61,13 +88,7 @@ const EmailWizardContent = ({
return () => { return () => {
cancelled = true cancelled = true
} }
}, [ }, [fetchObject, objectData?.objectType, objectIdsKey])
defaultCandidates,
fetchObject,
objectData?.object,
objectData?.objects,
objectData?.objectType
])
useEffect(() => { useEffect(() => {
setObjectData((previous) => ({ setObjectData((previous) => ({
@ -76,6 +97,23 @@ const EmailWizardContent = ({
})) }))
}, [candidates, setObjectData]) }, [candidates, setObjectData])
useEffect(() => {
const nextRecipients = candidatesToRecipients(candidates)
form.setFieldsValue({ recipients: nextRecipients })
setRecipientIndex((index) => (index < nextRecipients.length ? index : 0))
setObjectData((previous) => ({
...previous,
recipients: nextRecipients
}))
}, [candidates, form, setObjectData])
useEffect(() => {
setObjectData((previous) => {
if (previous?.currentRecipient === currentRecipient) return previous
return { ...previous, currentRecipient }
})
}, [currentRecipient, setObjectData])
useEffect(() => { useEffect(() => {
const currentAccount = objectData?.emailAccount const currentAccount = objectData?.emailAccount
const account = const account =
@ -91,18 +129,6 @@ const EmailWizardContent = ({
})) }))
}, [accounts, form, objectData?.emailAccount, setObjectData]) }, [accounts, form, objectData?.emailAccount, setObjectData])
useEffect(() => {
if (!candidates.length || objectData?.recipientEmail) return
const candidate = candidates[0]
const values = {
recipientEmail: candidate.email,
recipientType: candidate.recipientType,
recipient: candidate.recipient
}
form.setFieldsValue(values)
setObjectData((previous) => ({ ...previous, ...values }))
}, [candidates, form, objectData?.recipientEmail, setObjectData])
return ( return (
<WizardView <WizardView
steps={[ steps={[
@ -110,32 +136,60 @@ const EmailWizardContent = ({
title: 'Required', title: 'Required',
key: 'required', key: 'required',
content: ( content: (
<ObjectInfo <div>
type='emailMessage' <ObjectInfo
column={1} type='emailMessage'
visibleProperties={{ column={1}
name: true, visibleProperties={{
objectType: true, name: true,
objects: true, objectType: true,
emailTemplate: true, objects: true,
emailAccount: true, emailTemplate: true,
recipientEmail: true, emailAccount: true,
recipientType: true, fromEmail: true
recipient: true, }}
fromEmail: true bordered={false}
}} labelWidth={115}
bordered={false} isEditing
labelWidth={115} objectData={objectData}
isEditing />
objectData={objectData} <Form.Item
/> name='recipients'
rules={[
{
type: 'array',
min: 1,
message: 'At least one recipient is required'
}
]}
style={{ marginBottom: 0, marginTop: 16 }}
>
<ObjectChildCards
type='emailMessage'
name='recipients'
objectData={objectData}
isEditing
selectedIndex={recipientIndex}
onSelectedIndexChange={setRecipientIndex}
visibleProperties={{
object: true,
recipientEmail: true,
recipientType: true,
recipient: true
}}
emptyText='No recipients available'
/>
</Form.Item>
</div>
) )
} }
]} ]}
submitText='Send' submitText='Send'
title='Send Email' title='Send Email'
formValid={ formValid={
formValid && Boolean(objectData?.recipientEmail) && accounts.length > 0 formValid &&
recipients.some((recipient) => recipient?.recipientEmail) &&
accounts.length > 0
} }
loading={submitLoading} loading={submitLoading}
sideBarGrow sideBarGrow
@ -148,7 +202,9 @@ const EmailWizardContent = ({
}} }}
> >
<TemplatePreview <TemplatePreview
objectData={toTemplateObjectData(objectData?.objects)} objectData={toTemplateObjectData(
previewObject ? [previewObject] : []
)}
template={objectData?.emailTemplate} template={objectData?.emailTemplate}
templateType='emailTemplate' templateType='emailTemplate'
capabilities={{ capabilities={{
@ -198,10 +254,7 @@ const NewEmailMessage = ({
const fetchedMessageIdRef = useRef(null) const fetchedMessageIdRef = useRef(null)
fetchObjectRef.current = fetchObject fetchObjectRef.current = fetchObject
fetchObjectsRef.current = fetchObjects fetchObjectsRef.current = fetchObjects
const initialValues = useMemo( const initialValues = useMemo(() => ({ ...defaultValues }), [defaultValues])
() => ({ read: false, ...defaultValues }),
[defaultValues]
)
const loadAccounts = useCallback(async () => { const loadAccounts = useCallback(async () => {
const result = await fetchObjectsRef.current('emailAccount', { const result = await fetchObjectsRef.current('emailAccount', {
@ -303,7 +356,9 @@ const NewEmailMessage = ({
getContainer={() => document.body} getContainer={() => document.body}
> >
<ProgressDisplay <ProgressDisplay
percent={progressByState[sendingMessage?.state?.type] || 0} percent={Math.round(
(Number(sendingMessage?.state?.progress) || 0) * 100
)}
status={ status={
sendingMessage?.state?.type === 'error' ? 'exception' : 'active' sendingMessage?.state?.type === 'error' ? 'exception' : 'active'
} }

View File

@ -15,7 +15,7 @@ import MailIcon from '../../Icons/MailIcon'
import EmailTemplateIcon from '../../Icons/EmailTemplateIcon' import EmailTemplateIcon from '../../Icons/EmailTemplateIcon'
import NewEmailMessage from '../Management/EmailMessages/NewEmailMessage' import NewEmailMessage from '../Management/EmailMessages/NewEmailMessage'
import { import {
getDefaultEmailRecipient, candidatesToRecipients,
resolveEmailRecipients, resolveEmailRecipients,
resolveEmailRecipientsForObjects resolveEmailRecipientsForObjects
} from '../utils/emailRecipients' } from '../utils/emailRecipients'
@ -225,8 +225,8 @@ const EmailSendButton = ({
] ]
}, [handleTemplateSelect, templates]) }, [handleTemplateSelect, templates])
const defaultRecipient = useMemo( const defaultRecipients = useMemo(
() => getDefaultEmailRecipient(recipients), () => candidatesToRecipients(recipients),
[recipients] [recipients]
) )
@ -271,7 +271,7 @@ const EmailSendButton = ({
objectType: type, objectType: type,
objects: selectedObjects, objects: selectedObjects,
emailTemplate: selectedTemplate, emailTemplate: selectedTemplate,
...defaultRecipient recipients: defaultRecipients
}} }}
recipientCandidates={recipients} recipientCandidates={recipients}
/> />

View File

@ -0,0 +1,242 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import PropTypes from 'prop-types'
import { Button, Card, Flex, Space, Typography } from 'antd'
import ObjectInfo from './ObjectInfo'
import ChevronLeftIcon from '../../Icons/ChevronLeftIcon'
import ChevronRightIcon from '../../Icons/ChevronRightIcon'
import { getModelProperty } from '../../../database/ObjectModels'
const { Text } = Typography
const resolveChangeValue = (val, type) => {
if (type === 'bool') return val
if (val?.target && typeof val.target === 'object') {
return val.target.value
}
return val
}
const ObjectChildCards = ({
name,
type,
label,
properties: propertiesOverride,
columns: columnsOverride,
visibleProperties = {},
objectData = null,
parentData = null,
value,
isEditing = false,
onChange,
selectedIndex,
onSelectedIndexChange,
emptyText = 'No items',
bordered = false,
labelWidth = 115,
column = 1,
...cardProps
}) => {
const childProperty = useMemo(() => {
if (!type || !name) return null
return getModelProperty(type, name)
}, [name, type])
const properties = useMemo(() => {
if (Array.isArray(propertiesOverride) && propertiesOverride.length) {
return propertiesOverride
}
return Array.isArray(childProperty?.properties)
? childProperty.properties
: []
}, [childProperty, propertiesOverride])
const propertyDefinitions = useMemo(() => {
const columnNames =
Array.isArray(columnsOverride) && columnsOverride.length
? columnsOverride
: Array.isArray(childProperty?.columns)
? childProperty.columns
: []
if (!columnNames.length) return properties
const propertyMap = new Map(
properties.map((property) => [property.name, property])
)
return columnNames
.map((columnName) => propertyMap.get(columnName))
.filter(Boolean)
}, [childProperty, columnsOverride, properties])
const items = useMemo(() => {
if (Array.isArray(value)) return value
if (name && Array.isArray(objectData?.[name])) return objectData[name]
return []
}, [name, objectData, value])
const [internalIndex, setInternalIndex] = useState(0)
const isControlled = typeof onSelectedIndexChange === 'function'
const currentIndex = isControlled ? (selectedIndex ?? 0) : internalIndex
const setCurrentIndex = useCallback(
(nextIndex) => {
if (isControlled) {
onSelectedIndexChange(nextIndex)
return
}
setInternalIndex(nextIndex)
},
[isControlled, onSelectedIndexChange]
)
useEffect(() => {
if (!items.length) {
if (currentIndex !== 0) setCurrentIndex(0)
return
}
if (currentIndex > items.length - 1) {
setCurrentIndex(items.length - 1)
}
if (currentIndex < 0) {
setCurrentIndex(0)
}
}, [currentIndex, items.length, setCurrentIndex])
const currentRecord = items[currentIndex] || null
const handleStep = useCallback(
(delta) => {
const nextIndex = currentIndex + delta
if (nextIndex < 0 || nextIndex > items.length - 1) return
setCurrentIndex(nextIndex)
},
[currentIndex, items.length, setCurrentIndex]
)
const propertyMap = useMemo(() => {
const map = new Map()
properties.forEach((property) => {
if (property?.name) map.set(property.name, property)
})
return map
}, [properties])
const handlePropertyChange = useCallback(
(propName, newVal) => {
const property = propertyMap.get(propName)
const resolved = resolveChangeValue(newVal, property?.type)
const parent = parentData || objectData
const nextItems = items.map((item, index) => {
if (index !== currentIndex) return item
const updated = { ...item, [propName]: resolved }
properties.forEach((childProperty) => {
if (
childProperty?.name === propName ||
typeof childProperty?.value !== 'function'
) {
return
}
const computed = childProperty.value(updated, parent)
if (computed !== undefined) {
updated[childProperty.name] = computed
}
})
return updated
})
onChange?.(nextItems)
},
[
currentIndex,
items,
objectData,
onChange,
parentData,
properties,
propertyMap
]
)
const title = label || childProperty?.label || name || 'Items'
const countLabel = items.length
? `${currentIndex + 1}/${items.length}`
: '0/0'
return (
<Card
size='small'
title={
<Flex align='center' justify='space-between' gap='small'>
<Text>
{title} ({countLabel})
</Text>
</Flex>
}
extra={
<Space.Compact size='small' style={{ height: '22px' }}>
<Button
type='text'
icon={
<ChevronLeftIcon
style={{ fontSize: '10px', marginBottom: '6px' }}
/>
}
onClick={() => handleStep(-1)}
style={{ padding: 0, height: '22px', width: '32px' }}
disabled={!items.length || currentIndex <= 0}
/>
<Button
type='text'
icon={
<ChevronRightIcon
style={{ fontSize: '10px', marginBottom: '6px' }}
/>
}
onClick={() => handleStep(1)}
style={{ padding: 0, height: '22px', width: '32px' }}
disabled={!items.length || currentIndex >= items.length - 1}
/>
</Space.Compact>
}
styles={{ header: { padding: '0px 8px 0px 12px' } }}
{...cardProps}
>
{currentRecord ? (
<ObjectInfo
propertyDefinitions={propertyDefinitions}
objectData={currentRecord}
parentData={parentData || objectData}
isEditing={isEditing}
onPropertyChange={isEditing ? handlePropertyChange : undefined}
visibleProperties={visibleProperties}
column={column}
bordered={bordered}
labelWidth={labelWidth}
/>
) : (
<Text type='secondary'>{emptyText}</Text>
)}
</Card>
)
}
ObjectChildCards.propTypes = {
name: PropTypes.string,
type: PropTypes.string,
label: PropTypes.string,
properties: PropTypes.array,
columns: PropTypes.array,
visibleProperties: PropTypes.object,
objectData: PropTypes.object,
parentData: PropTypes.object,
value: PropTypes.array,
isEditing: PropTypes.bool,
onChange: PropTypes.func,
selectedIndex: PropTypes.number,
onSelectedIndexChange: PropTypes.func,
emptyText: PropTypes.string,
bordered: PropTypes.bool,
labelWidth: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
column: PropTypes.oneOfType([PropTypes.number, PropTypes.object])
}
export default ObjectChildCards

View File

@ -5,7 +5,7 @@ const normalizeType = (type) =>
? `${type.charAt(0).toLowerCase()}${type.slice(1)}` ? `${type.charAt(0).toLowerCase()}${type.slice(1)}`
: type : type
const getReferenceId = (value) => { export const getReferenceId = (value) => {
if (typeof value === 'string') return value if (typeof value === 'string') return value
return value?._id?._id || value?._id || null return value?._id?._id || value?._id || null
} }
@ -14,7 +14,7 @@ const addCandidate = (candidates, candidate) => {
if (!candidate?.email) return if (!candidate?.email) return
const key = `${candidate.email.toLowerCase()}:${candidate.recipientType || ''}:${ const key = `${candidate.email.toLowerCase()}:${candidate.recipientType || ''}:${
getReferenceId(candidate.recipient) || '' getReferenceId(candidate.recipient) || ''
}` }:${getReferenceId(candidate.object) || ''}`
if (!candidates.some((entry) => entry.key === key)) { if (!candidates.some((entry) => entry.key === key)) {
candidates.push({ ...candidate, key }) candidates.push({ ...candidate, key })
} }
@ -40,7 +40,8 @@ export const resolveEmailRecipients = async ({
addCandidate(candidates, { addCandidate(candidates, {
email: value.trim(), email: value.trim(),
label: `${property.label || property.name}: ${value.trim()}`, label: `${property.label || property.name}: ${value.trim()}`,
property: property.name property: property.name,
object: objectData
}) })
continue continue
} }
@ -71,7 +72,8 @@ export const resolveEmailRecipients = async ({
}`, }`,
property: property.name, property: property.name,
recipientType: resolvedType, recipientType: resolvedType,
recipient recipient,
object: objectData
}) })
} }
} }
@ -79,6 +81,15 @@ export const resolveEmailRecipients = async ({
return candidates return candidates
} }
const hydrateObject = async (item, type, fetchObject) => {
if (item && typeof item === 'object' && !Array.isArray(item)) {
return item
}
const id = getReferenceId(item)
if (!id || !type || typeof fetchObject !== 'function') return null
return fetchObject(id, type)
}
export const resolveEmailRecipientsForObjects = async ({ export const resolveEmailRecipientsForObjects = async ({
type, type,
objects, objects,
@ -86,40 +97,53 @@ export const resolveEmailRecipientsForObjects = async ({
}) => { }) => {
const list = Array.isArray(objects) ? objects.filter(Boolean) : [] const list = Array.isArray(objects) ? objects.filter(Boolean) : []
const merged = [] const merged = []
for (const objectData of list) { for (const item of list) {
const objectData = await hydrateObject(item, type, fetchObject)
if (!objectData) continue
const result = await resolveEmailRecipients({ const result = await resolveEmailRecipients({
type, type,
objectData, objectData,
fetchObject fetchObject
}) })
for (const candidate of result) { if (result.length) {
addCandidate(merged, candidate) for (const candidate of result) {
addCandidate(merged, candidate)
}
} else {
merged.push({
email: '',
object: objectData,
key: `:${getReferenceId(objectData) || ''}`
})
} }
} }
return merged return merged
} }
export const candidatesToRecipients = (candidates = []) => {
if (!Array.isArray(candidates) || !candidates.length) return []
return candidates.map((candidate) => ({
recipientEmail: candidate.email || '',
recipientType: candidate.recipientType ?? null,
recipient: candidate.recipient ?? null,
object: candidate.object ?? null,
read: false,
sent: false
}))
}
/** Prefer a client/vendor ref; otherwise use a plain email candidate. */ /** Prefer a client/vendor ref; otherwise use a plain email candidate. */
export const getDefaultEmailRecipient = (candidates = []) => { export const getDefaultEmailRecipient = (candidates = []) => {
if (!Array.isArray(candidates) || !candidates.length) return null const recipients = candidatesToRecipients(candidates)
if (!recipients.length) return null
const objectCandidate = candidates.find( const objectRecipient = recipients.find(
(candidate) => (recipient) =>
(candidate.recipientType === 'client' || (recipient.recipientType === 'client' ||
candidate.recipientType === 'vendor') && recipient.recipientType === 'vendor') &&
candidate.recipient && recipient.recipient &&
candidate.email recipient.recipientEmail
) )
if (objectCandidate) { return objectRecipient || recipients[0]
return {
recipientEmail: objectCandidate.email,
recipientType: objectCandidate.recipientType,
recipient: objectCandidate.recipient
}
}
const emailCandidate = candidates.find((candidate) => candidate.email)
if (!emailCandidate) return null
return { recipientEmail: emailCandidate.email }
} }

View File

@ -11,11 +11,9 @@ export function resolveObjectsList(objects, objectData) {
export function toTemplateObjectData(objects) { export function toTemplateObjectData(objects) {
const list = Array.isArray(objects) ? objects.filter(Boolean) : [] const list = Array.isArray(objects) ? objects.filter(Boolean) : []
if (!list.length) return {} if (!list.length) return {}
if (list.length === 1) { const first = list[0]
const first = list[0] if (first && typeof first === 'object' && !Array.isArray(first)) {
if (first && typeof first === 'object' && !Array.isArray(first)) { return { ...first, objects: list }
return first
}
} }
return { objects: list } return { objects: list }
} }

View File

@ -15,7 +15,28 @@ const NewEmailMessage = lazy(
import('../../components/Dashboard/Management/EmailMessages/NewEmailMessage') import('../../components/Dashboard/Management/EmailMessages/NewEmailMessage')
) )
const readOnlyAfterCreate = (data) => data?._id != null const readOnlyAfterCreate = (data, parentData) =>
(parentData?._id ?? data?._id) != null
const getReferenceId = (value) => {
if (typeof value === 'string') return value
return value?._id?._id || value?._id || null
}
const matchRecipientCandidate = (data, parentData) => {
if (
!Array.isArray(parentData?._recipientCandidates) ||
!parentData._recipientCandidates.length
) {
return undefined
}
const objectId = getReferenceId(data?.object)
return parentData._recipientCandidates.find((candidate) => {
if (candidate.email !== data?.recipientEmail) return false
if (!objectId) return true
return getReferenceId(candidate.object) === objectId
})
}
export const EmailMessage = { export const EmailMessage = {
name: 'emailMessage', name: 'emailMessage',
@ -74,49 +95,38 @@ export const EmailMessage = {
'_reference', '_reference',
'name', 'name',
'state', 'state',
'recipientEmail',
'fromEmail', 'fromEmail',
'messageId',
'emailTemplate', 'emailTemplate',
'emailAccount', 'emailAccount',
'objectType', 'objectType',
'sentAt', 'sentAt',
'readAt', 'firstReadAt',
'lastReadAt',
'createdAt', 'createdAt',
'updatedAt' 'updatedAt'
], ],
filters: [ filters: [
'name', 'name',
'state', 'state',
'recipientEmail',
'fromEmail', 'fromEmail',
'messageId',
'emailTemplate', 'emailTemplate',
'emailAccount', 'emailAccount',
'objectType', 'objectType',
'recipientType',
'read',
'sentAt', 'sentAt',
'readAt', 'firstReadAt',
'lastReadAt',
'createdAt', 'createdAt',
'updatedAt', 'updatedAt',
'_reference' '_reference'
], ],
group: [ group: ['state', 'emailTemplate', 'emailAccount', 'objectType'],
'state',
'emailTemplate',
'emailAccount',
'objectType',
'recipientType'
],
sorters: [ sorters: [
'name', 'name',
'state', 'state',
'recipientEmail',
'fromEmail', 'fromEmail',
'messageId',
'sentAt', 'sentAt',
'readAt', 'firstReadAt',
'lastReadAt',
'createdAt', 'createdAt',
'updatedAt' 'updatedAt'
], ],
@ -171,6 +181,18 @@ export const EmailMessage = {
label: 'Sent At', label: 'Sent At',
type: 'dateTime', type: 'dateTime',
readOnly: true, readOnly: true,
value: (data) => {
const recipients = Array.isArray(data?.recipients)
? data.recipients
: []
return recipients.reduce((latest, recipient) => {
if (!recipient?.sent || recipient.sentAt == null) return latest
if (latest == null) return recipient.sentAt
return new Date(recipient.sentAt) > new Date(latest)
? recipient.sentAt
: latest
}, undefined)
},
columnWidth: 200 columnWidth: 200
}, },
{ {
@ -182,19 +204,24 @@ export const EmailMessage = {
columnWidth: 240 columnWidth: 240
}, },
{ {
name: 'readAt', name: 'firstReadAt',
label: 'Read At', label: 'First Read At',
type: 'dateTime', type: 'dateTime',
readOnly: true, readOnly: true,
value: (data) => {
const recipients = Array.isArray(data?.recipients)
? data.recipients
: []
return recipients.reduce((earliest, recipient) => {
if (recipient?.readAt == null) return earliest
if (earliest == null) return recipient.readAt
return new Date(recipient.readAt) < new Date(earliest)
? recipient.readAt
: earliest
}, undefined)
},
columnWidth: 200 columnWidth: 200
}, },
{
name: 'read',
label: 'Read',
type: 'bool',
readOnly: true,
columnWidth: 100
},
{ {
name: 'emailTemplate', name: 'emailTemplate',
label: 'Template', label: 'Template',
@ -210,7 +237,178 @@ export const EmailMessage = {
}), }),
columnWidth: 200 columnWidth: 200
}, },
{
name: 'lastReadAt',
label: 'Last Read At',
type: 'dateTime',
readOnly: true,
value: (data) => {
const recipients = Array.isArray(data?.recipients)
? data.recipients
: []
return recipients.reduce((latest, recipient) => {
if (recipient?.readAt == null) return latest
if (latest == null) return recipient.readAt
return new Date(recipient.readAt) > new Date(latest)
? recipient.readAt
: latest
}, undefined)
},
columnWidth: 200
},
{
name: 'emailAccount',
label: 'Email Account',
type: 'object',
objectType: 'emailAccount',
required: true,
readOnly: readOnlyAfterCreate,
showHyperlink: true,
masterFilter: { active: true },
columnWidth: 200
},
{
name: 'recipients',
label: 'Recipients',
type: 'objectChildren',
required: true,
canAddRemove: true,
size: 'medium',
hiddenPropertyWidth: 160,
columns: [
'object',
'recipientEmail',
'recipientType',
'recipient',
'messageId',
'attachments',
'read',
'sent',
'readAt'
],
properties: [
{
name: 'object',
label: 'Object',
type: 'object',
objectType: (_data, parentData) => parentData?.objectType,
readOnly: true,
showHyperlink: true,
columnWidth: 200
},
{
name: 'recipientEmail',
label: 'Recipient Email',
type: 'email',
required: true,
readOnly: readOnlyAfterCreate,
columnWidth: 260
},
{
name: 'recipientType',
label: 'Recipient Type',
type: 'objectType',
readOnly: readOnlyAfterCreate,
masterFilter: ['client', 'vendor'],
value: (data, parentData) => {
const match = matchRecipientCandidate(data, parentData)
if (!match) return undefined
return match.recipientType ?? null
},
columnWidth: 160
},
{
name: 'recipient',
label: 'Recipient',
type: 'object',
objectType: (data) => data?.recipientType,
readOnly: readOnlyAfterCreate,
showHyperlink: true,
value: (data, parentData) => {
const match = matchRecipientCandidate(data, parentData)
if (!match) return undefined
return match.recipient ?? null
},
columnWidth: 200
},
{
name: 'messageId',
label: 'Message ID',
type: 'miscId',
readOnly: true,
showCopy: true,
columnWidth: 280
},
{
name: 'pixelId',
label: 'Pixel ID',
type: 'miscId',
readOnly: true,
showCopy: true,
columnWidth: 280
},
{
name: 'attachments',
label: 'Attachments',
type: 'objectList',
objectType: 'file',
readOnly: true,
showHyperlink: true,
columnWidth: 230
},
{
name: 'read',
label: 'Read',
type: 'bool',
readOnly: true,
columnWidth: 100
},
{
name: 'sent',
label: 'Sent',
type: 'bool',
readOnly: true,
columnWidth: 100
},
{
name: 'readAt',
label: 'Read At',
type: 'dateTime',
readOnly: true,
columnWidth: 200
},
{
name: 'sentAt',
label: 'Sent At',
type: 'dateTime',
readOnly: true,
columnWidth: 200
}
]
},
{
name: 'fromEmail',
label: 'From Email',
type: 'email',
required: true,
readOnly: true,
value: (data) => data?.emailAccount?.fromEmail,
columnWidth: 260
},
{
name: 'subject',
label: 'Rendered Subject',
type: 'text',
readOnly: true,
columnWidth: 300
},
{
name: 'content',
label: 'Rendered Body',
type: 'codeBlock',
language: 'html',
readOnly: true
},
{ {
name: 'objectType', name: 'objectType',
label: 'Object Type', label: 'Object Type',
@ -228,115 +426,6 @@ export const EmailMessage = {
readOnly: readOnlyAfterCreate, readOnly: readOnlyAfterCreate,
showHyperlink: true, showHyperlink: true,
columnWidth: 200 columnWidth: 200
},
{
name: 'emailAccount',
label: 'Email Account',
type: 'object',
objectType: 'emailAccount',
required: true,
readOnly: readOnlyAfterCreate,
showHyperlink: true,
masterFilter: { active: true },
columnWidth: 200
},
{
name: 'recipientEmail',
label: 'Recipient Email',
type: 'email',
required: true,
readOnly: readOnlyAfterCreate,
options: (data) =>
Array.isArray(data?._recipientCandidates) &&
data._recipientCandidates.length
? data._recipientCandidates.map((candidate) => ({
value: candidate.email,
label: candidate.label
}))
: undefined,
columnWidth: 260
},
{
name: 'recipientType',
label: 'Recipient Type',
type: 'objectType',
readOnly: readOnlyAfterCreate,
value: (data) => {
if (
!Array.isArray(data?._recipientCandidates) ||
!data._recipientCandidates.length
) {
return undefined
}
const match = data._recipientCandidates.find(
(candidate) => candidate.email === data?.recipientEmail
)
if (!match) return undefined
return match.recipientType ?? null
},
columnWidth: 160
},
{
name: 'recipient',
label: 'Recipient',
type: 'object',
objectType: (data) => data?.recipientType,
readOnly: readOnlyAfterCreate,
showHyperlink: true,
value: (data) => {
if (
!Array.isArray(data?._recipientCandidates) ||
!data._recipientCandidates.length
) {
return undefined
}
const match = data._recipientCandidates.find(
(candidate) => candidate.email === data?.recipientEmail
)
if (!match) return undefined
return match.recipient ?? null
},
columnWidth: 200
},
{
name: 'fromEmail',
label: 'From Email',
type: 'email',
required: true,
readOnly: true,
value: (data) => data?.emailAccount?.fromEmail,
columnWidth: 260
},
{
name: 'messageId',
label: 'Message ID',
type: 'text',
readOnly: true,
showCopy: true,
columnWidth: 280
},
{
name: 'subject',
label: 'Rendered Subject',
type: 'text',
readOnly: true,
columnWidth: 300
},
{
name: 'content',
label: 'Rendered Body',
type: 'codeBlock',
language: 'html',
readOnly: true
},
{
name: 'attachments',
label: 'Attachments',
type: 'objectList',
objectType: 'file',
readOnly: true,
showHyperlink: true,
columnWidth: 230
} }
] ]
} }