From da2881d505b73e90c4584dd23bb9606ecc2c16f7 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Tue, 15 Sep 2026 01:56:51 +0100 Subject: [PATCH] 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. --- src/codemirror/fcTemplateLang/index.js | 6 +- .../EmailMessages/EmailMessageInfo.jsx | 26 +- .../EmailMessages/NewEmailMessage.jsx | 179 ++++++--- .../Dashboard/common/EmailSendButton.jsx | 8 +- .../Dashboard/common/ObjectChildCards.jsx | 242 ++++++++++++ .../Dashboard/utils/emailRecipients.js | 76 ++-- .../Dashboard/utils/templateObjectData.js | 8 +- src/database/models/EmailMessage.js | 365 +++++++++++------- 8 files changed, 672 insertions(+), 238 deletions(-) create mode 100644 src/components/Dashboard/common/ObjectChildCards.jsx 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={ + +