Tom Butcher 483682ce44
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
Add Email Account and Email Message Management Components
- Introduced new EmailAccounts and EmailMessages components for managing email accounts and messages.
- Implemented ObjectTable for displaying data with filtering and sorting capabilities.
- Integrated InfoActionButtons for enhanced user interactions across various management functionalities.
- Refactored existing components to replace DocumentPrintButton with InfoActionButtons for consistency in action handling.
- Added EmailAccountInfo component for detailed view and management of individual email accounts.
2026-09-12 21:40:18 +01:00

776 lines
22 KiB
JavaScript

import {
useState,
useContext,
useEffect,
useLayoutEffect,
useRef,
useCallback
} from 'react'
import PropTypes from 'prop-types'
import { Flex, Button, Input, InputNumber, Select, Card, Popover } from 'antd'
import PlusIcon from '../../Icons/PlusIcon.jsx'
import MinusIcon from '../../Icons/MinusIcon.jsx'
import InfoCircleIcon from '../../Icons/InfoCircleIcon.jsx'
import PanIcon from '../../Icons/PanIcon.jsx'
import PanFilledIcon from '../../Icons/PanFilledIcon.jsx'
import ObjectProperty from '../common/ObjectProperty.jsx'
import PDFViewer from './PDFViewer.jsx'
import ScrollBox from './ScrollBox.jsx'
import { ApiServerContext } from '../context/ApiServerContext.jsx'
import ProgressDisplay from './ProgressDisplay.jsx'
import { CaretDownFilled } from '@ant-design/icons'
const noop = () => {}
const TemplatePreview = ({
objectData,
documentTemplate,
content = null,
template,
templateType = 'documentTemplate',
capabilities = {},
loading,
isEditing,
onTestObjectOpen = noop,
onPreviewMessage = noop,
showTestObject = false,
showPreviewSwitch = true
}) => {
const currentTemplate = template || documentTemplate
const htmlOnly = capabilities.htmlOnly === true
const showWidthControl = capabilities.widthControl === true
const defaultPreviewWidth = capabilities.defaultWidth || 600
const iframeRef = useRef(null)
const scrollElementRef = useRef(null)
const savedScrollRef = useRef({ top: 0, left: 0 })
const iframeSizeObserverRef = useRef(null)
const previewRequestIdRef = useRef(0)
const renderUnsubscribeRef = useRef(null)
const {
fetchTemplatePreview,
fetchTemplateDownload,
subscribeToObjectEvent
} = useContext(ApiServerContext)
const [previewContentHTML, setPreviewContentHTML] = useState('')
const [pdfBlob, setPDFBlob] = useState(null)
const [reloadLoading, setReloadLoading] = useState(false)
const [downloadProgress, setDownloadProgress] = useState(null)
const [previewScale, setPreviewScale] = useState(1)
const [previewType, setPreviewType] = useState('HTML')
const [previewWidth, setPreviewWidth] = useState(defaultPreviewWidth)
const [panMode, setPanMode] = useState(false)
const [isPanning, setIsPanning] = useState(false)
const panStartRef = useRef({ x: 0, y: 0, scrollLeft: 0, scrollTop: 0 })
const [iframeSize, setIframeSize] = useState({
width: '100%',
height: 'auto'
})
const toolbarRef = useRef(null)
const [usePopoverSettings, setUsePopoverSettings] = useState(false)
useLayoutEffect(() => {
const toolbar = toolbarRef.current
if (!toolbar) return undefined
const updateLayout = () => {
const { width, height } = toolbar.getBoundingClientRect()
console.log('Preview card size', { width, height })
setUsePopoverSettings(width < 620)
}
updateLayout()
const observer = new ResizeObserver(updateLayout)
observer.observe(toolbar)
return () => observer.disconnect()
}, [])
const applyIframeContentSize = useCallback(() => {
const iframe = iframeRef.current
const doc = iframe?.contentDocument
if (!iframe || !doc?.documentElement) {
return
}
const wrappers = doc.querySelectorAll('.previewWrapper')
wrappers.forEach((wrapper) => {
const page = wrapper.querySelector('.previewDocument')
if (!page) {
return
}
const pageVisual = page.getBoundingClientRect()
if (pageVisual.width <= 0 && pageVisual.height <= 0) {
return
}
wrapper.style.width = `${pageVisual.width}px`
wrapper.style.height = `${pageVisual.height}px`
})
const documentEl =
doc.querySelector('.previewPages') ||
doc.querySelector('.previewDocument') ||
doc.querySelector('.previewWrapper') ||
doc.body
const container = doc.querySelector('.previewContainer')
const visual = documentEl.getBoundingClientRect()
if (visual.width <= 0 && visual.height <= 0) {
return
}
let padX = 0
let padY = 0
if (container) {
const containerStyle = doc.defaultView.getComputedStyle(container)
padX =
parseFloat(containerStyle.paddingLeft) +
parseFloat(containerStyle.paddingRight)
padY =
parseFloat(containerStyle.paddingTop) +
parseFloat(containerStyle.paddingBottom)
}
const contentWidth = showWidthControl
? previewWidth
: Math.ceil(visual.width + padX)
const contentHeight = Math.ceil(
Math.max(visual.height + padY, doc.documentElement.scrollHeight)
)
if (!doc.head?.querySelector('style[data-preview-outer-scroll]')) {
const styleEl = doc.createElement('style')
styleEl.setAttribute('data-preview-outer-scroll', 'true')
styleEl.textContent =
'html, body { overflow: hidden !important; height: auto !important; }'
doc.head?.appendChild(styleEl)
}
setIframeSize((prev) => {
const width = `${contentWidth}px`
const height = `${contentHeight}px`
if (prev.width === width && prev.height === height) {
return prev
}
return { width, height }
})
}, [previewWidth, showWidthControl])
const restoreScrollPosition = useCallback(() => {
const scrollEl = scrollElementRef.current
if (!scrollEl) {
return
}
scrollEl.scrollTo(savedScrollRef.current.left, savedScrollRef.current.top)
}, [])
const handleIframeLoad = useCallback(() => {
iframeSizeObserverRef.current?.disconnect()
iframeSizeObserverRef.current = null
if (previewType != 'HTML') {
return
}
applyIframeContentSize()
const doc = iframeRef.current?.contentDocument
const observed =
doc?.querySelector('.previewPages') ||
doc?.querySelector('.previewContainer') ||
doc?.querySelector('.previewDocument') ||
doc?.body
if (observed && typeof ResizeObserver === 'function') {
const observer = new ResizeObserver(() => {
applyIframeContentSize()
})
observer.observe(observed)
iframeSizeObserverRef.current = observer
}
}, [applyIframeContentSize, previewType])
useLayoutEffect(() => {
restoreScrollPosition()
}, [iframeSize, restoreScrollPosition])
const updatePreviewContentHTML = (html) => {
const scrollEl = scrollElementRef.current
if (scrollEl) {
savedScrollRef.current = {
top: scrollEl.scrollTop,
left: scrollEl.scrollLeft
}
}
setPreviewContentHTML(html)
}
const clearIframeContents = useCallback(() => {
iframeSizeObserverRef.current?.disconnect()
iframeSizeObserverRef.current = null
const iframe = iframeRef.current
if (iframe) {
iframe.srcdoc = ''
}
setPreviewContentHTML('')
setIframeSize({
width: showWidthControl ? `${previewWidth}px` : '100%',
height: 'auto'
})
}, [previewWidth, showWidthControl])
const stopRenderProgressListener = useCallback(() => {
if (typeof renderUnsubscribeRef.current === 'function') {
renderUnsubscribeRef.current()
}
renderUnsubscribeRef.current = null
}, [])
const applyPdfResult = useCallback(
(result, requestId) => {
if (requestId !== previewRequestIdRef.current) {
return
}
setReloadLoading(false)
setDownloadProgress(null)
stopRenderProgressListener()
if (result?.error) {
onPreviewMessage(result.error, true)
return
}
setPDFBlob(
new Blob([result.buffer || result.pdf], {
type: result.mime || 'application/pdf'
})
)
onPreviewMessage('No issues found.', false)
},
[onPreviewMessage, stopRenderProgressListener]
)
const reloadPreviewPDF = useCallback(
(content, testObject = {}) => {
const templateId = currentTemplate?._id
if (!templateId) {
return
}
const requestId = ++previewRequestIdRef.current
stopRenderProgressListener()
setPDFBlob(null)
setReloadLoading(true)
setDownloadProgress({ progress: 0, message: 'Starting render...' })
const startDownload = async () => {
try {
const startResult = await fetchTemplateDownload(
templateId,
content,
testObject,
'pdf',
{ async: true }
)
if (requestId !== previewRequestIdRef.current) {
return
}
if (startResult?.error || !startResult?.renderRequestId) {
applyPdfResult(startResult, requestId)
return
}
const renderRequestId = startResult.renderRequestId
const eventType = `render:${renderRequestId}`
let fetched = false
const fetchRenderedFile = async () => {
if (fetched) {
return true
}
try {
const result = await fetchTemplateDownload(
templateId,
content,
testObject,
'pdf',
{ renderRequestId }
)
fetched = true
applyPdfResult(result, requestId)
return true
} catch (err) {
if (err?.response?.status === 404) {
return false
}
applyPdfResult(
{
error: err?.message || 'Failed to download rendered template.'
},
requestId
)
return true
}
}
const unsubscribe = subscribeToObjectEvent(
renderRequestId,
'render',
eventType,
(event) => {
if (requestId !== previewRequestIdRef.current) {
return
}
setDownloadProgress({
progress: Number(event?.progress) || 0,
message: event?.message || ''
})
if (event?.error) {
applyPdfResult({ error: event.error }, requestId)
return
}
if (Number(event?.progress) >= 1) {
fetchRenderedFile()
}
}
)
const pollId = window.setInterval(async () => {
if (requestId !== previewRequestIdRef.current) {
window.clearInterval(pollId)
return
}
const didFetch = await fetchRenderedFile()
if (didFetch) {
window.clearInterval(pollId)
}
}, 1000)
renderUnsubscribeRef.current = () => {
window.clearInterval(pollId)
if (typeof unsubscribe === 'function') {
unsubscribe()
}
}
} catch (err) {
applyPdfResult(
{ error: err?.message || 'Failed to start template download.' },
requestId
)
}
}
startDownload()
},
[
applyPdfResult,
currentTemplate?._id,
fetchTemplateDownload,
stopRenderProgressListener,
subscribeToObjectEvent
]
)
const reloadPreview = useCallback(
(content, testObject = {}, scale = 1) => {
const templateId = currentTemplate?._id
if (!templateId) {
return
}
const requestId = ++previewRequestIdRef.current
setReloadLoading(true)
fetchTemplatePreview(
templateId,
content,
testObject,
scale,
(result) => {
if (requestId !== previewRequestIdRef.current) {
return
}
setReloadLoading(false)
if (result?.error) {
onPreviewMessage(result.error, true)
} else {
updatePreviewContentHTML(result.html)
onPreviewMessage('No issues found.', false)
}
},
templateType,
showWidthControl ? { width: previewWidth } : {}
)
},
[
currentTemplate?._id,
fetchTemplatePreview,
onPreviewMessage,
previewWidth,
showWidthControl,
templateType
]
)
const templateContent = currentTemplate?.content
const templateId = currentTemplate?._id
useEffect(() => {
if (content != null) {
updatePreviewContentHTML(content)
return
}
if (!templateId || !templateContent || previewType != 'HTML') {
return
}
const timeoutId = window.setTimeout(() => {
if (content == null) {
reloadPreview(templateContent, objectData, previewScale)
}
}, 300)
return () => {
window.clearTimeout(timeoutId)
}
}, [
objectData,
templateContent,
templateId,
previewScale,
previewWidth,
previewType,
reloadPreview,
content
])
useEffect(() => {
if (!templateId || !templateContent || previewType != 'PDF') {
return
}
setPDFBlob(null)
const timeoutId = window.setTimeout(() => {
reloadPreviewPDF(templateContent, objectData)
}, 300)
return () => {
window.clearTimeout(timeoutId)
}
}, [objectData, templateContent, templateId, previewType, reloadPreviewPDF])
useEffect(() => {
return () => {
iframeSizeObserverRef.current?.disconnect()
stopRenderProgressListener()
}
}, [stopRenderProgressListener])
useEffect(() => {
if (!panMode) {
setIsPanning(false)
}
}, [panMode])
useEffect(() => {
const scrollEl = scrollElementRef.current
if (!panMode || !scrollEl || previewType != 'HTML') {
return
}
const handlePointerDown = (event) => {
if (event.pointerType === 'mouse' && event.button !== 0) {
return
}
event.preventDefault()
panStartRef.current = {
x: event.clientX,
y: event.clientY,
scrollLeft: scrollEl.scrollLeft,
scrollTop: scrollEl.scrollTop
}
setIsPanning(true)
}
scrollEl.addEventListener('pointerdown', handlePointerDown)
return () => {
scrollEl.removeEventListener('pointerdown', handlePointerDown)
}
}, [panMode, previewType])
useEffect(() => {
if (!isPanning) {
return
}
const previousCursor = document.body.style.cursor
const previousUserSelect = document.body.style.userSelect
document.body.style.cursor = 'grabbing'
document.body.style.userSelect = 'none'
const handlePointerMove = (event) => {
const scrollEl = scrollElementRef.current
if (!scrollEl) {
return
}
const { x, y, scrollLeft, scrollTop } = panStartRef.current
scrollEl.scrollLeft = scrollLeft - (event.clientX - x)
scrollEl.scrollTop = scrollTop - (event.clientY - y)
}
const handlePointerUp = () => {
setIsPanning(false)
}
window.addEventListener('pointermove', handlePointerMove)
window.addEventListener('pointerup', handlePointerUp)
window.addEventListener('pointercancel', handlePointerUp)
return () => {
document.body.style.cursor = previousCursor
document.body.style.userSelect = previousUserSelect
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', handlePointerUp)
window.removeEventListener('pointercancel', handlePointerUp)
}
}, [isPanning])
const showProgressOverlay = previewType != 'HTML' && downloadProgress != null
const toolbarContent = (
<>
<Button
icon={
panMode ? (
<PanFilledIcon style={{ color: 'var(--color-primary)' }} />
) : (
<PanIcon />
)
}
disabled={loading || reloadLoading}
onClick={() => {
setPanMode((prev) => !prev)
}}
/>
<Button
icon={<PlusIcon />}
onClick={() => {
setPreviewScale((prev) => prev + 0.05)
}}
disabled={loading || reloadLoading}
/>
<Button
readOnly={true}
style={{ width: '65px' }}
disabled={loading || reloadLoading}
onClick={() => {
setPreviewScale(1)
}}
>
{previewScale.toFixed(2)}x
</Button>
<Button
icon={<MinusIcon />}
onClick={() => {
setPreviewScale((prev) => Math.max(0.1, prev - 0.05))
}}
disabled={loading || reloadLoading}
/>
{showWidthControl ? (
<InputNumber
min={240}
max={1600}
step={10}
addonAfter='px'
value={previewWidth}
disabled={loading || reloadLoading}
onChange={(value) => setPreviewWidth(value || defaultPreviewWidth)}
style={{ width: 130 }}
aria-label='Email preview width'
/>
) : null}
{showPreviewSwitch == true && !htmlOnly ? (
<Select
options={[
{ value: 'HTML', label: 'HTML' },
{ value: 'PDF', label: 'PDF' }
]}
style
loading={(loading || reloadLoading) && !usePopoverSettings}
disabled={loading || reloadLoading}
value={previewType}
onChange={(value) => {
if (value == 'PDF') {
clearIframeContents()
setPDFBlob(null)
}
setPreviewType(value)
}}
/>
) : null}
</>
)
return (
<Flex vertical gap={'middle'} style={{ height: '100%' }}>
<Flex gap={'small'} ref={toolbarRef}>
{showTestObject == true ? (
<>
{currentTemplate?.objectType ? (
<ObjectProperty
objectType={currentTemplate?.objectType}
name={'testObject'}
isEditing={true}
objectData={currentTemplate}
disabled={!isEditing || currentTemplate?.global}
type={'object'}
style={{ minWidth: 0 }}
/>
) : (
<div style={{ flexGrow: 1 }}>
<Input disabled={true} />
</div>
)}
<Button
icon={<InfoCircleIcon />}
disabled={currentTemplate?.global}
onClick={() => {
onTestObjectOpen()
}}
/>
</>
) : null}
{!usePopoverSettings ? (
toolbarContent
) : (
<Popover
content={<Flex gap={'small'}>{toolbarContent}</Flex>}
trigger={['hover', 'click']}
placement='bottomRight'
arrow={false}
>
<Button
icon={<CaretDownFilled />}
loading={loading || reloadLoading}
/>
</Popover>
)}
</Flex>
<div
style={{
flex: '1 1 auto',
minHeight: 0,
border: '1px solid #85858541',
position: 'relative'
}}
>
<div
className={`previewProgressOverlay ${showProgressOverlay ? 'visible' : 'hidden'}`}
>
{showProgressOverlay ? (
<Flex
style={{ width: '100%', height: '100%' }}
align='center'
justify='center'
>
<Card
style={{
maxWidth: 360,
minWidth: 100,
width: '100%',
margin: 24
}}
styles={{ body: { padding: 18 } }}
>
<ProgressDisplay
percent={Math.round(
(Number(downloadProgress.progress) || 0) * 100
)}
status='active'
>
{downloadProgress.message || 'Rendering...'}
</ProgressDisplay>
</Card>
</Flex>
) : null}
</div>
{previewType == 'HTML' ? (
<ScrollBox
scrollableNodeProps={{ ref: scrollElementRef }}
style={{
cursor: panMode ? (isPanning ? 'grabbing' : 'grab') : undefined,
touchAction: panMode ? 'none' : undefined
}}
>
<Flex
justify='center'
align='center'
style={{
minWidth: '100%',
width: iframeSize.width,
height: iframeSize.height
}}
>
<div
style={{
width: iframeSize.width,
height: iframeSize.height,
cursor: panMode
? isPanning
? 'grabbing'
: 'grab'
: undefined
}}
>
<iframe
ref={iframeRef}
srcDoc={previewContentHTML}
onLoad={handleIframeLoad}
scrolling='no'
frameBorder='0'
style={{
width: '100%',
height: iframeSize.height === 'auto' ? 150 : '100%',
display: 'block',
border: 0,
overflow: 'hidden',
pointerEvents: panMode ? 'none' : undefined
}}
/>
</div>
</Flex>
</ScrollBox>
) : (
<PDFViewer
file={pdfBlob}
scale={previewScale}
panMode={panMode}
loading={false}
onLoadError={() => {
onPreviewMessage('Failed to render PDF preview.', true)
}}
/>
)}
</div>
</Flex>
)
}
TemplatePreview.propTypes = {
loading: PropTypes.bool,
objectData: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
documentTemplate: PropTypes.object,
template: PropTypes.object,
templateType: PropTypes.string,
capabilities: PropTypes.object,
isEditing: PropTypes.bool,
style: PropTypes.object,
showTestObject: PropTypes.bool,
onTestObjectOpen: PropTypes.func,
onPreviewMessage: PropTypes.func,
showPreviewSwitch: PropTypes.bool,
content: PropTypes.string
}
export default TemplatePreview