Compare commits

..

3 Commits

Author SHA1 Message Date
968dc0667a Enhance ObjectSelect Component with Improved State Management and Value Handling
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Introduced refs for loaded keys and tree select value to optimize state management and prevent unnecessary re-renders.
- Refactored getValueIdentity function to handle various data types more effectively, including null and boolean values.
- Updated selection handling logic to ensure accurate comparisons and prevent redundant updates during value changes.
- Enhanced loading logic to track loaded nodes and improve the rendering of child components based on selection state.
- Implemented additional useEffect hooks to synchronize state changes with component updates, enhancing overall responsiveness.
2026-09-15 01:56:57 +01:00
da2881d505 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.
2026-09-15 01:56:51 +01:00
c9751dd5ef Enhance fcTemplateLang Schema with New Style Attributes and Placeholder Support
- Added 'margin', 'header', and 'footer' to the list of style attributes, allowing for more flexible styling options.
- Updated the logic for 'vertical', 'header', and 'footer' to include boolean values in their attribute definitions.
- Introduced pagination placeholders ('!pageNumber' and '!totalPages') to support dynamic content in templates.
- Implemented a completion source function for placeholders, enhancing user experience by providing context-aware suggestions.
2026-09-15 01:02:07 +01:00
10 changed files with 831 additions and 281 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

@ -3,8 +3,11 @@
* Mirrors custom elements / style attrs handled in farmcontrol-ws TemplateManager.
*/
import { syntaxTree } from '@codemirror/language'
const styleAttrs = [
'padding',
'margin',
'width',
'height',
'minWidth',
@ -17,6 +20,8 @@ const styleAttrs = [
'border',
'borderRadius',
'vertical',
'header',
'footer',
'grow',
'shrink',
'color',
@ -48,7 +53,9 @@ const alignValues = [
export const fcTemplateAttributes = [
...styleAttrs.map((name) => {
const attr = { name, global: true }
if (name === 'vertical') attr.values = ['true', 'false']
if (name === 'vertical' || name === 'header' || name === 'footer') {
attr.values = ['true', 'false']
}
if (name === 'justify') attr.values = justifyValues
if (name === 'align') attr.values = alignValues
if (name === 'textAlign') {
@ -141,6 +148,69 @@ export const fcEmailTemplateHelpers = {
}
}
/** Pagination placeholders replaced after layout (`!pageNumber`, `!totalPages`). */
export const fcTemplatePlaceholders = [
{
name: '!pageNumber',
detail: 'Current page number'
},
{
name: '!totalPages',
detail: 'Total page count'
}
]
export function placeholderCompletionSource() {
const options = fcTemplatePlaceholders.map((placeholder) => ({
label: placeholder.name,
apply: placeholder.name,
type: 'variable',
detail: placeholder.detail
}))
return (context) => {
if (isPlaceholderBlockedContext(context.state, context.pos)) {
return null
}
const before = context.state.sliceDoc(Math.max(0, context.pos - 48), context.pos)
const match = before.match(/![a-zA-Z]*$/)
if (!match) {
return null
}
return {
from: context.pos - match[0].length,
options
}
}
}
function isPlaceholderBlockedContext(state, position) {
let node = syntaxTree(state).resolveInner(position, -1)
while (node) {
if (
node.name === 'AttributeValue' ||
node.name === 'AttributeName' ||
node.name === 'Attribute' ||
node.name === 'OpenTag' ||
node.name === 'CloseTag' ||
node.name === 'StartTag' ||
node.name === 'TagName' ||
node.name === 'JavascriptExpression' ||
node.name === 'Scriptlet' ||
node.name === 'Output' ||
node.name === 'Comment'
) {
return true
}
node = node.parent
}
return false
}
export const fcTemplateHelpers = fcDocumentTemplateHelpers
export function getFcTemplateHelpers(templateType) {

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,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'
}

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

@ -251,6 +251,8 @@ const ObjectSelect = ({
const masterFilterRef = useRef(masterFilter)
const clearedMissingValueRef = useRef(false)
const loadGenerationRef = useRef(0)
const loadedKeysRef = useRef(new Set())
const treeSelectValueRef = useRef(null)
const getSelectKey = useCallback(
(selectType, selectMasterFilter) =>
`${selectType}::${JSON.stringify(selectMasterFilter ?? {})}`,
@ -269,31 +271,25 @@ const ObjectSelect = ({
// Normalize a value to an identity string so we can detect in-place _id updates
const getValueIdentity = useCallback((val) => {
if (val && typeof val === 'object') {
// Handle arrays
if (Array.isArray(val)) {
const ids = val
.map((item) => {
if (item && typeof item === 'object') {
if (item._id) return String(item._id)
if (
item.value &&
typeof item.value === 'object' &&
item.value._id
)
return String(item.value._id)
}
return null
})
.filter(Boolean)
.sort()
return ids.length > 0 ? ids.join(',') : JSON.stringify(val)
}
// Handle single objects
if (val._id) return String(val._id)
if (val.value && typeof val.value === 'object' && val.value._id)
return String(val.value._id)
if (val == null || val === '') return ''
if (
typeof val === 'string' ||
typeof val === 'number' ||
typeof val === 'boolean'
) {
return String(val).toLowerCase()
}
if (typeof val !== 'object') return String(val)
if (Array.isArray(val)) {
const ids = val
.map((item) => getValueIdentity(item))
.filter(Boolean)
.sort()
return ids.join(',')
}
if (val._id) return String(val._id).toLowerCase()
if (val.value != null) return getValueIdentity(val.value)
return JSON.stringify(val)
}, [])
const prevValueIdentityRef = useRef(getValueIdentity(value))
@ -426,11 +422,7 @@ const ObjectSelect = ({
masterFilterRef.current
)
) {
if (itemExists) {
reloadRef.current?.()
} else {
silentReloadRef.current?.()
}
silentReloadRef.current?.()
return
}
@ -445,7 +437,7 @@ const ObjectSelect = ({
}, [])
const newEventHandler = useCallback(() => {
reloadRef.current?.()
silentReloadRef.current?.()
}, [])
const reloadRef = useRef(null)
@ -536,8 +528,8 @@ const ObjectSelect = ({
newFilterPath,
objects
)
const resolvedChildren =
nodeChildren.length === 0 ? undefined : nodeChildren
const loaded =
nodeChildren.length > 0 || loadedKeysRef.current.has(nodeKey)
const modelProperty = getModelProperty(type, property)
return {
title: <ObjectProperty {...modelProperty} value={value} />,
@ -549,7 +541,8 @@ const ObjectSelect = ({
filterPath: newFilterPath,
selectable: false,
isLeaf: false,
children: resolvedChildren
loaded,
children: loaded ? nodeChildren : undefined
}
})
.filter(Boolean)
@ -608,6 +601,7 @@ const ObjectSelect = ({
async (node) => {
if (!node.property) return
if (type == 'unknown') return
if (node.key) loadedKeysRef.current.add(node.key)
await handleFetchObjectsProperties(buildFilterFromNode(node))
},
[buildFilterFromNode, handleFetchObjectsProperties, type]
@ -696,6 +690,13 @@ const ObjectSelect = ({
const onTreeSelectChange = useCallback(
(nextValue) => {
if (
getValueIdentity(nextValue) ===
getValueIdentity(treeSelectValueRef.current)
) {
return
}
const isEmptySelection = multiple
? !Array.isArray(nextValue) || nextValue.length === 0
: nextValue == null || nextValue === ''
@ -744,7 +745,7 @@ const ObjectSelect = ({
}
onChange?.(null)
},
[multiple, onChange, findObjectById]
[multiple, onChange, findObjectById, getValueIdentity]
)
const onSearch = useCallback(
@ -856,6 +857,10 @@ const ObjectSelect = ({
treeDataRef.current = treeData
}, [treeData])
useEffect(() => {
treeSelectValueRef.current = treeSelectValue
}, [treeSelectValue])
const prevValuesRef = useRef({ type, masterFilter })
useEffect(() => {
@ -877,6 +882,7 @@ const ObjectSelect = ({
treeDataRef.current = []
setTreeVersion((v) => v + 1)
setExpandedKeys([])
loadedKeysRef.current = new Set()
setInitialized(false)
valueRef.current = null
setTreeSelectValue(null)
@ -899,13 +905,16 @@ const ObjectSelect = ({
const changeSource = isInternalChangeRef.current ? 'internal' : 'external'
if (changeSource == 'external') {
loadGenerationRef.current += 1
setObjectPropertiesTree({})
setTreeData([])
treeDataRef.current = []
setInitialized(false)
setInitialLoading(true)
valueRef.current = null
const nextId = getValueId(value)
const alreadyInTree = isValueInTree(treeDataRef.current, nextId)
const alreadySelected =
treeSelectValueRef.current != null &&
getValueIdentity(treeSelectValueRef.current) === currentValueIdentity
if (!alreadyInTree && !alreadySelected) {
loadGenerationRef.current += 1
}
clearedMissingValueRef.current = false
setValueNotFound(false)
}
@ -924,6 +933,40 @@ const ObjectSelect = ({
const generation = loadGenerationRef.current
const handleValue = async () => {
if (generation !== loadGenerationRef.current) return
const valueIdentity = getValueIdentity(value)
const ids = multiple
? (Array.isArray(value) ? value.map(getValueId) : [])
: value == null
? []
: [getValueId(value)]
const allInTree =
ids.length > 0 &&
ids.every(
(id) =>
id == null ||
id === '' ||
isValueInTree(treeDataRef.current, id) ||
findObjectById(id) != null
)
if (
value != null &&
type != 'unknown' &&
allInTree &&
getValueIdentity(valueRef.current) !== valueIdentity
) {
valueRef.current = value
setTreeSelectValue(
multiple
? ids.map((id) => toSelectValue(id)).filter((id) => id != null)
: toSelectValue(ids[0])
)
setInitialized(true)
setInitialLoading(false)
return
}
if (
multiple &&
Array.isArray(value) &&
@ -975,6 +1018,7 @@ const ObjectSelect = ({
}
setExpandedKeys([...new Set(pathKeys)])
pathKeys.forEach((key) => loadedKeysRef.current.add(key))
setTreeSelectValue(
value
.map((item) => toSelectValue(getValueId(item)))
@ -1025,6 +1069,7 @@ const ObjectSelect = ({
}
})
setExpandedKeys(pathKeys)
pathKeys.forEach((key) => loadedKeysRef.current.add(key))
const data = await handleFetchObjectsProperties(valueFilter)
if (generation !== loadGenerationRef.current) return
if (Array.isArray(data)) applyTreeFromData(data)
@ -1069,7 +1114,8 @@ const ObjectSelect = ({
connected,
getValueIdentity,
multiple,
applyTreeFromData
applyTreeFromData,
findObjectById
])
useEffect(() => {

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
})
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]
}

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
}
const first = list[0]
if (first && typeof first === 'object' && !Array.isArray(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,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
}
]
}