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

View File

@ -8,8 +8,10 @@ import useCollapseState from '../../hooks/useCollapseState.jsx'
import NotesPanel from '../../common/NotesPanel.jsx'
import InfoCollapse from '../../common/InfoCollapse.jsx'
import ObjectInfo from '../../common/ObjectInfo.jsx'
import ObjectProperty from '../../common/ObjectProperty.jsx'
import ViewButton from '../../common/ViewButton.jsx'
import InfoCircleIcon from '../../../Icons/InfoCircleIcon.jsx'
import EmailMessageIcon from '../../../Icons/EmailMessageIcon.jsx'
import NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
import ObjectForm from '../../common/ObjectForm.jsx'
@ -21,7 +23,7 @@ import ObjectTable from '../../common/ObjectTable.jsx'
import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx'
import InfoActionButtons from '../../common/InfoActionButtons.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 TemplatePreview from '../../common/TemplatePreview.jsx'
@ -38,6 +40,7 @@ const EmailMessageInfo = () => {
'EmailMessageInfo',
{
info: true,
recipients: true,
notes: true,
auditLogs: false
}
@ -87,6 +90,7 @@ const EmailMessageInfo = () => {
disabled={objectFormState.loading}
items={[
{ key: 'info', label: 'Email Message Information' },
{ key: 'recipients', label: 'Recipients' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]}
@ -141,10 +145,28 @@ const EmailMessageInfo = () => {
objectData={objectData}
labelWidth='190px'
visibleProperties={{
content: false
content: false,
recipients: false
}}
/>
</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
title='Email Message Content'
icon={<InfoCircleIcon />}

View File

@ -7,23 +7,21 @@ import {
useRef,
useState
} from 'react'
import { Modal } from 'antd'
import { Form, Modal } from 'antd'
import ObjectInfo from '../../common/ObjectInfo'
import ObjectChildCards from '../../common/ObjectChildCards'
import NewObjectForm from '../../common/NewObjectForm'
import WizardView from '../../common/WizardView'
import TemplatePreview from '../../common/TemplatePreview'
import ProgressDisplay from '../../common/ProgressDisplay'
import { ApiServerContext } from '../../context/ApiServerContext'
import { resolveEmailRecipientsForObjects } from '../../utils/emailRecipients'
import {
candidatesToRecipients,
getReferenceId,
resolveEmailRecipientsForObjects
} from '../../utils/emailRecipients'
import { toTemplateObjectData } from '../../utils/templateObjectData'
const progressByState = {
queued: 10,
rendering: 35,
sending: 70,
sent: 100,
error: 100
}
const EMPTY_RECIPIENT_CANDIDATES = []
const EMPTY_DEFAULT_VALUES = {}
@ -40,20 +38,49 @@ const EmailWizardContent = ({
}) => {
const { fetchObject } = useContext(ApiServerContext)
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(() => {
if (defaultCandidates.length) {
setCandidates(defaultCandidates)
return
const objects = relatedObjectsRef.current
if (!objects.length) {
setCandidates([])
return undefined
}
let cancelled = false
resolveEmailRecipientsForObjects({
type: objectData?.objectType,
objects: Array.isArray(objectData?.objects)
? objectData.objects
: objectData?.object
? [objectData.object]
: [],
objects,
fetchObject
}).then((result) => {
if (!cancelled) setCandidates(result)
@ -61,13 +88,7 @@ const EmailWizardContent = ({
return () => {
cancelled = true
}
}, [
defaultCandidates,
fetchObject,
objectData?.object,
objectData?.objects,
objectData?.objectType
])
}, [fetchObject, objectData?.objectType, objectIdsKey])
useEffect(() => {
setObjectData((previous) => ({
@ -76,6 +97,23 @@ const EmailWizardContent = ({
}))
}, [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(() => {
const currentAccount = objectData?.emailAccount
const account =
@ -91,18 +129,6 @@ const EmailWizardContent = ({
}))
}, [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 (
<WizardView
steps={[
@ -110,6 +136,7 @@ const EmailWizardContent = ({
title: 'Required',
key: 'required',
content: (
<div>
<ObjectInfo
type='emailMessage'
column={1}
@ -119,9 +146,6 @@ const EmailWizardContent = ({
objects: true,
emailTemplate: true,
emailAccount: true,
recipientEmail: true,
recipientType: true,
recipient: true,
fromEmail: true
}}
bordered={false}
@ -129,13 +153,43 @@ const EmailWizardContent = ({
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'
title='Send Email'
formValid={
formValid && Boolean(objectData?.recipientEmail) && accounts.length > 0
formValid &&
recipients.some((recipient) => recipient?.recipientEmail) &&
accounts.length > 0
}
loading={submitLoading}
sideBarGrow
@ -148,7 +202,9 @@ const EmailWizardContent = ({
}}
>
<TemplatePreview
objectData={toTemplateObjectData(objectData?.objects)}
objectData={toTemplateObjectData(
previewObject ? [previewObject] : []
)}
template={objectData?.emailTemplate}
templateType='emailTemplate'
capabilities={{
@ -198,10 +254,7 @@ const NewEmailMessage = ({
const fetchedMessageIdRef = useRef(null)
fetchObjectRef.current = fetchObject
fetchObjectsRef.current = fetchObjects
const initialValues = useMemo(
() => ({ read: false, ...defaultValues }),
[defaultValues]
)
const initialValues = useMemo(() => ({ ...defaultValues }), [defaultValues])
const loadAccounts = useCallback(async () => {
const result = await fetchObjectsRef.current('emailAccount', {
@ -303,7 +356,9 @@ const NewEmailMessage = ({
getContainer={() => document.body}
>
<ProgressDisplay
percent={progressByState[sendingMessage?.state?.type] || 0}
percent={Math.round(
(Number(sendingMessage?.state?.progress) || 0) * 100
)}
status={
sendingMessage?.state?.type === 'error' ? 'exception' : 'active'
}

View File

@ -15,7 +15,7 @@ import MailIcon from '../../Icons/MailIcon'
import EmailTemplateIcon from '../../Icons/EmailTemplateIcon'
import NewEmailMessage from '../Management/EmailMessages/NewEmailMessage'
import {
getDefaultEmailRecipient,
candidatesToRecipients,
resolveEmailRecipients,
resolveEmailRecipientsForObjects
} from '../utils/emailRecipients'
@ -225,8 +225,8 @@ const EmailSendButton = ({
]
}, [handleTemplateSelect, templates])
const defaultRecipient = useMemo(
() => getDefaultEmailRecipient(recipients),
const defaultRecipients = useMemo(
() => candidatesToRecipients(recipients),
[recipients]
)
@ -271,7 +271,7 @@ const EmailSendButton = ({
objectType: type,
objects: selectedObjects,
emailTemplate: selectedTemplate,
...defaultRecipient
recipients: defaultRecipients
}}
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
const getReferenceId = (value) => {
export const getReferenceId = (value) => {
if (typeof value === 'string') return value
return value?._id?._id || value?._id || null
}
@ -14,7 +14,7 @@ const addCandidate = (candidates, candidate) => {
if (!candidate?.email) return
const key = `${candidate.email.toLowerCase()}:${candidate.recipientType || ''}:${
getReferenceId(candidate.recipient) || ''
}`
}:${getReferenceId(candidate.object) || ''}`
if (!candidates.some((entry) => entry.key === key)) {
candidates.push({ ...candidate, key })
}
@ -40,7 +40,8 @@ export const resolveEmailRecipients = async ({
addCandidate(candidates, {
email: value.trim(),
label: `${property.label || property.name}: ${value.trim()}`,
property: property.name
property: property.name,
object: objectData
})
continue
}
@ -71,7 +72,8 @@ export const resolveEmailRecipients = async ({
}`,
property: property.name,
recipientType: resolvedType,
recipient
recipient,
object: objectData
})
}
}
@ -79,6 +81,15 @@ export const resolveEmailRecipients = async ({
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 ({
type,
objects,
@ -86,40 +97,53 @@ export const resolveEmailRecipientsForObjects = async ({
}) => {
const list = Array.isArray(objects) ? objects.filter(Boolean) : []
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({
type,
objectData,
fetchObject
})
if (result.length) {
for (const candidate of result) {
addCandidate(merged, candidate)
}
} else {
merged.push({
email: '',
object: objectData,
key: `:${getReferenceId(objectData) || ''}`
})
}
}
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. */
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(
(candidate) =>
(candidate.recipientType === 'client' ||
candidate.recipientType === 'vendor') &&
candidate.recipient &&
candidate.email
const objectRecipient = recipients.find(
(recipient) =>
(recipient.recipientType === 'client' ||
recipient.recipientType === 'vendor') &&
recipient.recipient &&
recipient.recipientEmail
)
if (objectCandidate) {
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 }
return objectRecipient || recipients[0]
}

View File

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

View File

@ -15,7 +15,28 @@ const NewEmailMessage = lazy(
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 = {
name: 'emailMessage',
@ -74,49 +95,38 @@ export const EmailMessage = {
'_reference',
'name',
'state',
'recipientEmail',
'fromEmail',
'messageId',
'emailTemplate',
'emailAccount',
'objectType',
'sentAt',
'readAt',
'firstReadAt',
'lastReadAt',
'createdAt',
'updatedAt'
],
filters: [
'name',
'state',
'recipientEmail',
'fromEmail',
'messageId',
'emailTemplate',
'emailAccount',
'objectType',
'recipientType',
'read',
'sentAt',
'readAt',
'firstReadAt',
'lastReadAt',
'createdAt',
'updatedAt',
'_reference'
],
group: [
'state',
'emailTemplate',
'emailAccount',
'objectType',
'recipientType'
],
group: ['state', 'emailTemplate', 'emailAccount', 'objectType'],
sorters: [
'name',
'state',
'recipientEmail',
'fromEmail',
'messageId',
'sentAt',
'readAt',
'firstReadAt',
'lastReadAt',
'createdAt',
'updatedAt'
],
@ -171,6 +181,18 @@ export const EmailMessage = {
label: 'Sent At',
type: 'dateTime',
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
},
{
@ -182,18 +204,23 @@ export const EmailMessage = {
columnWidth: 240
},
{
name: 'readAt',
label: 'Read At',
name: 'firstReadAt',
label: 'First Read At',
type: 'dateTime',
readOnly: true,
columnWidth: 200
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)
},
{
name: 'read',
label: 'Read',
type: 'bool',
readOnly: true,
columnWidth: 100
columnWidth: 200
},
{
name: 'emailTemplate',
@ -210,7 +237,178 @@ export const EmailMessage = {
}),
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',
label: 'Object Type',
@ -228,115 +426,6 @@ export const EmailMessage = {
readOnly: readOnlyAfterCreate,
showHyperlink: true,
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
}
]
}