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:
parent
c9751dd5ef
commit
da2881d505
@ -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)
|
||||
|
||||
@ -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 />}
|
||||
|
||||
@ -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,32 +136,60 @@ const EmailWizardContent = ({
|
||||
title: 'Required',
|
||||
key: 'required',
|
||||
content: (
|
||||
<ObjectInfo
|
||||
type='emailMessage'
|
||||
column={1}
|
||||
visibleProperties={{
|
||||
name: true,
|
||||
objectType: true,
|
||||
objects: true,
|
||||
emailTemplate: true,
|
||||
emailAccount: true,
|
||||
recipientEmail: true,
|
||||
recipientType: true,
|
||||
recipient: true,
|
||||
fromEmail: true
|
||||
}}
|
||||
bordered={false}
|
||||
labelWidth={115}
|
||||
isEditing
|
||||
objectData={objectData}
|
||||
/>
|
||||
<div>
|
||||
<ObjectInfo
|
||||
type='emailMessage'
|
||||
column={1}
|
||||
visibleProperties={{
|
||||
name: true,
|
||||
objectType: true,
|
||||
objects: true,
|
||||
emailTemplate: true,
|
||||
emailAccount: true,
|
||||
fromEmail: true
|
||||
}}
|
||||
bordered={false}
|
||||
labelWidth={115}
|
||||
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'
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
242
src/components/Dashboard/common/ObjectChildCards.jsx
Normal file
242
src/components/Dashboard/common/ObjectChildCards.jsx
Normal 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
|
||||
@ -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]
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user