diff --git a/src/codemirror/fcTemplateLang/index.js b/src/codemirror/fcTemplateLang/index.js
index a5978250..84188706 100644
--- a/src/codemirror/fcTemplateLang/index.js
+++ b/src/codemirror/fcTemplateLang/index.js
@@ -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)
diff --git a/src/components/Dashboard/Management/EmailMessages/EmailMessageInfo.jsx b/src/components/Dashboard/Management/EmailMessages/EmailMessageInfo.jsx
index 798218b1..b092217b 100644
--- a/src/components/Dashboard/Management/EmailMessages/EmailMessageInfo.jsx
+++ b/src/components/Dashboard/Management/EmailMessages/EmailMessageInfo.jsx
@@ -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
}}
/>
+ }
+ active={collapseState.recipients}
+ onToggle={(expanded) =>
+ updateCollapseState('recipients', expanded)
+ }
+ collapseKey='recipients'
+ >
+
+
}
diff --git a/src/components/Dashboard/Management/EmailMessages/NewEmailMessage.jsx b/src/components/Dashboard/Management/EmailMessages/NewEmailMessage.jsx
index 738ffefd..5ec2200b 100644
--- a/src/components/Dashboard/Management/EmailMessages/NewEmailMessage.jsx
+++ b/src/components/Dashboard/Management/EmailMessages/NewEmailMessage.jsx
@@ -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 (
+
+
+
+
+
+
)
}
]}
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 = ({
}}
>
({ 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}
>
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}
/>
diff --git a/src/components/Dashboard/common/ObjectChildCards.jsx b/src/components/Dashboard/common/ObjectChildCards.jsx
new file mode 100644
index 00000000..3d6e52cf
--- /dev/null
+++ b/src/components/Dashboard/common/ObjectChildCards.jsx
@@ -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 (
+
+
+ {title} ({countLabel})
+
+
+ }
+ extra={
+
+
+ }
+ onClick={() => handleStep(-1)}
+ style={{ padding: 0, height: '22px', width: '32px' }}
+ disabled={!items.length || currentIndex <= 0}
+ />
+
+ }
+ onClick={() => handleStep(1)}
+ style={{ padding: 0, height: '22px', width: '32px' }}
+ disabled={!items.length || currentIndex >= items.length - 1}
+ />
+
+ }
+ styles={{ header: { padding: '0px 8px 0px 12px' } }}
+ {...cardProps}
+ >
+ {currentRecord ? (
+
+ ) : (
+ {emptyText}
+ )}
+
+ )
+}
+
+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
diff --git a/src/components/Dashboard/utils/emailRecipients.js b/src/components/Dashboard/utils/emailRecipients.js
index 4d836d91..6fb244ff 100644
--- a/src/components/Dashboard/utils/emailRecipients.js
+++ b/src/components/Dashboard/utils/emailRecipients.js
@@ -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
})
- for (const candidate of result) {
- addCandidate(merged, candidate)
+ 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]
}
diff --git a/src/components/Dashboard/utils/templateObjectData.js b/src/components/Dashboard/utils/templateObjectData.js
index 39e87ffa..462b682d 100644
--- a/src/components/Dashboard/utils/templateObjectData.js
+++ b/src/components/Dashboard/utils/templateObjectData.js
@@ -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
- }
+ const first = list[0]
+ if (first && typeof first === 'object' && !Array.isArray(first)) {
+ return { ...first, objects: list }
}
return { objects: list }
}
diff --git a/src/database/models/EmailMessage.js b/src/database/models/EmailMessage.js
index 26c2a9e8..bda69913 100644
--- a/src/database/models/EmailMessage.js
+++ b/src/database/models/EmailMessage.js
@@ -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,19 +204,24 @@ export const EmailMessage = {
columnWidth: 240
},
{
- name: 'readAt',
- label: 'Read At',
+ name: 'firstReadAt',
+ label: 'First Read At',
type: 'dateTime',
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
},
- {
- name: 'read',
- label: 'Read',
- type: 'bool',
- readOnly: true,
- columnWidth: 100
- },
{
name: 'emailTemplate',
label: 'Template',
@@ -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
}
]
}