Tom Butcher f55a09ff1f
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
Refactor TemplateEditor to Improve Loading State Handling
- Wrapped the TemplatePreview component in a Spin component to provide a loading indicator while content is being loaded.
- Enhanced the structure of the TemplatePreview component by ensuring proper style and props are passed, improving user experience during loading states.
2026-08-21 23:15:47 +01:00

349 lines
9.8 KiB
JavaScript

import { useState, useCallback, useContext } from 'react'
import PropTypes from 'prop-types'
import {
Flex,
Alert,
Card,
Spin,
Splitter,
Button,
Modal,
Segmented,
Popover,
Typography
} from 'antd'
import { LoadingOutlined, CaretDownOutlined } from '@ant-design/icons'
import ExclamationOctagonIcon from '../../Icons/ExclamationOctagonIcon.jsx'
import CheckCircleIcon from '../../Icons/CheckCircleIcon.jsx'
import MagicWandIcon from '../../Icons/MagicWandIcon.jsx'
import ObjectProperty from '../common/ObjectProperty.jsx'
import TemplatePreview from './TemplatePreview.jsx'
import DataTree from './DataTree.jsx'
import { ApiServerContext } from '../context/ApiServerContext.jsx'
const { Text } = Typography
const monoStyle = {
whiteSpace: 'pre',
display: 'block',
fontFamily: 'monospace'
}
const EJSErrorFormatter = ({ message }) => {
const renderLine = (line, index) => {
const errorLineMatch = line.match(/^(\s*)>>\s*(\d+)\|(.*)$/)
if (errorLineMatch) {
const [, indent, lineNum, content] = errorLineMatch
return (
<Text key={index} style={monoStyle}>
{indent}
<span style={{ color: 'var(--color-error)' }}>{'>>'}</span> {lineNum}|
{content || '\u00A0'}
</Text>
)
}
const contextLineMatch = line.match(/^(\s+)(\d+)\|(.*)$/)
if (contextLineMatch) {
const [, indent, lineNum, content] = contextLineMatch
return (
<Text key={index} style={monoStyle}>
{indent}
<span style={{ opacity: 0.5 }}>{lineNum}</span>|{content || '\u00A0'}
</Text>
)
}
if (/^ejs:\d+/.test(line)) {
return (
<Text key={index} style={{ ...monoStyle, opacity: 0.75 }}>
{line}
</Text>
)
}
return (
<Text key={index} style={monoStyle}>
{line || '\u00A0'}
</Text>
)
}
return <Flex vertical>{message.split('\n').map(renderLine)}</Flex>
}
EJSErrorFormatter.propTypes = {
message: PropTypes.string.isRequired
}
const parseEjsErrorLines = (message) => {
if (!message || typeof message !== 'string') {
return []
}
const lines = new Set()
const headerMatch = message.match(/^ejs:(\d+)/m)
if (headerMatch) {
lines.add(Number(headerMatch[1]))
}
for (const match of message.matchAll(/^\s*>>\s*(\d+)\|/gm)) {
lines.add(Number(match[1]))
}
return [...lines]
}
const TemplateEditor = ({
objectData,
loading,
collapseState,
isEditing,
style,
form,
setObjectData
}) => {
const { fetchTemplateFormat } = useContext(ApiServerContext)
const [testObjectOpen, setTestObjectOpen] = useState(false)
const [testObjectViewMode, setTestObjectViewMode] = useState('Tree')
const [previewMessage, setPreviewMessage] = useState('No issues found.')
const [previewError, setPreviewError] = useState(false)
const [errorLines, setErrorLines] = useState([])
const [formatLoading, setFormatLoading] = useState(false)
//const isMobile = useMediaQuery({ maxWidth: 768 })
const handlePreviewMessage = useCallback((message, isError) => {
if (isError) {
setPreviewMessage(
<Flex gap={'small'} align={'center'} justify={'space-between'}>
Compile error.
<Popover
content={<EJSErrorFormatter message={message} />}
trigger={['hover', 'click']}
placement='bottomRight'
arrow={false}
overlayStyle={{ minWidth: '560px' }}
>
<Button
type={'text'}
size={'small'}
icon={
<CaretDownOutlined
style={{
marginLeft: '0.5px',
marginBottom: '2.5px',
fontSize: '12.5px'
}}
/>
}
/>
</Popover>
</Flex>
)
} else {
setPreviewMessage(message)
}
setPreviewError(isError)
setErrorLines(isError ? parseEjsErrorLines(message) : [])
}, [])
const handleFormatCode = useCallback(async () => {
const content = form?.getFieldValue?.('content') ?? objectData?.content
if (content == null || typeof content !== 'string') {
return
}
setFormatLoading(true)
try {
const result = await fetchTemplateFormat(content)
if (result?.error) {
handlePreviewMessage(result.error, true)
return
}
if (result?.content == null) {
return
}
form?.setFieldValue?.('content', result.content)
setObjectData?.((prev) => ({
...prev,
content: result.content
}))
} finally {
setFormatLoading(false)
}
}, [
form,
objectData?.content,
fetchTemplateFormat,
handlePreviewMessage,
setObjectData
])
return (
<>
<Splitter className={'farmcontrol-splitter'} vertical={true}>
{collapseState.preview == true && (
<Splitter.Panel style={{ height: '100%' }}>
<Spin spinning={loading} indicator={<LoadingOutlined />}>
<Card
spinning={loading}
style={style}
styles={{ body: { height: '100%', padding: '22px' } }}
>
<TemplatePreview
objectData={objectData?.testObject}
documentTemplate={objectData}
loading={loading}
isEditing={isEditing}
style={style}
onTestObjectOpen={() => setTestObjectOpen(true)}
onPreviewMessage={handlePreviewMessage}
showTestObject={true}
/>
</Card>
</Spin>
</Splitter.Panel>
)}
{collapseState.editor == true && (
<Splitter.Panel>
<Spin spinning={loading} indicator={<LoadingOutlined />}>
<Card
style={style}
styles={{ body: { height: '100%', padding: '22px' } }}
>
<Flex vertical gap={'middle'} style={{ height: '100%' }}>
<Flex gap={'small'} align={'stretch'}>
<Alert
message={previewMessage}
showIcon
style={{
padding: '4px 5px 4px 8px',
flex: '1 1 auto',
minWidth: 0
}}
icon={
previewError ? (
<ExclamationOctagonIcon />
) : (
<CheckCircleIcon />
)
}
type={previewError ? 'error' : 'success'}
/>
{isEditing && (
<Button
icon={<MagicWandIcon />}
loading={formatLoading}
disabled={loading || formatLoading}
onClick={handleFormatCode}
/>
)}
</Flex>
<div
style={{
width: '100%',
height: '100%',
flex: '1 1 auto',
minHeight: 0
}}
>
<ObjectProperty
style={{
width: '100%',
height: '100%',
flex: '1 1 auto',
minHeight: 0
}}
useFormItem={false}
name={'content'}
height='100%'
type='codeBlock'
language={'fcTemplateLang'}
objectData={objectData}
onChange={(value) => {
form?.setFieldValue?.('content', value)
setObjectData?.((prev) => ({
...prev,
content: value
}))
}}
isEditing={isEditing}
autoCompleteObject={objectData?.testObject}
errorLines={errorLines}
/>
</div>
</Flex>
</Card>
</Spin>
</Splitter.Panel>
)}
</Splitter>
<Modal
open={testObjectOpen}
closeIcon={null}
width={800}
footer={
<Button
onClick={() => {
setTestObjectOpen(false)
}}
>
Close
</Button>
}
>
<Flex gap={'small'} vertical>
<div>
<Segmented
options={['Tree', 'Code']}
value={testObjectViewMode}
onChange={(value) => setTestObjectViewMode(value)}
size='small'
/>
</div>
<div
style={{
maxHeight: 'calc(var(--unit-100vh) - 280px)',
overflowY: 'scroll'
}}
>
{testObjectViewMode == 'Code' && (
<ObjectProperty
type={'codeBlock'}
name='testObject'
language='json'
objectData={objectData}
isEditing={true}
/>
)}
{testObjectViewMode == 'Tree' && (
<DataTree
data={objectData?.testObject}
defaultExpandAll={true}
showValueCopy={false}
showKeyCopy={true}
/>
)}
</div>
</Flex>
</Modal>
</>
)
}
TemplateEditor.propTypes = {
loading: PropTypes.bool,
objectData: PropTypes.object,
collapseState: PropTypes.object,
isEditing: PropTypes.bool,
style: PropTypes.object,
form: PropTypes.object,
setObjectData: PropTypes.func
}
export default TemplateEditor