diff --git a/src/codemirror/fcTemplateLang/index.js b/src/codemirror/fcTemplateLang/index.js
index 6ffb6c4e..a5978250 100644
--- a/src/codemirror/fcTemplateLang/index.js
+++ b/src/codemirror/fcTemplateLang/index.js
@@ -4,7 +4,8 @@ import {
indentNodeProp,
delimitedIndent,
foldNodeProp,
- foldInside
+ foldInside,
+ syntaxTree
} from '@codemirror/language'
import { styleTags, tags as t } from '@lezer/highlight'
import { parseMixed } from '@lezer/common'
@@ -81,13 +82,64 @@ function buildJsScope(autoCompleteObject, templateType) {
return typed
}
+function isResourceValueContext(state, position) {
+ let node = syntaxTree(state).resolveInner(position, -1)
+ let insideTemplateInterpolation = false
+
+ while (node) {
+ if (node.name === 'Interpolation') {
+ insideTemplateInterpolation = true
+ }
+ if (node.name === 'AttributeValue' || node.name === 'String') {
+ return true
+ }
+ if (node.name === 'TemplateString') {
+ return !insideTemplateInterpolation
+ }
+ node = node.parent
+ }
+
+ return false
+}
+
+function resourceCompletionSource(resourceNames) {
+ const options = [...new Set(resourceNames)]
+ .filter((name) => typeof name === 'string' && name.length > 0)
+ .map((name) => ({
+ label: `@[${name}]`,
+ apply: `@[${name}]`,
+ type: 'variable',
+ detail: 'Template resource'
+ }))
+
+ return (context) => {
+ if (options.length === 0) return null
+
+ const before = context.state.sliceDoc(0, context.pos)
+ const match = before.match(/@(?:\[[^\]\r\n]*)?$/)
+ if (!match || !isResourceValueContext(context.state, context.pos)) {
+ return null
+ }
+
+ return {
+ from: context.pos - match[0].length,
+ options
+ }
+ }
+}
+
/**
* Language support for FarmControl EJS+XML document templates.
- * @param {{ autoCompleteObject?: object|string, templateType?: string }} [options]
+ * @param {{ autoCompleteObject?: object|string, templateType?: string, resourceNames?: string[] }} [options]
*/
export function fcTemplateLang(options = {}) {
- const { autoCompleteObject, templateType = 'documentTemplate' } = options
+ const {
+ autoCompleteObject,
+ templateType = 'documentTemplate',
+ resourceNames = []
+ } = options
const jsScope = buildJsScope(autoCompleteObject, templateType)
+ const completeResources = resourceCompletionSource(resourceNames)
return new LanguageSupport(fcTemplateLanguage, [
xmlLanguage.data.of({
@@ -96,6 +148,8 @@ export function fcTemplateLang(options = {}) {
fcTemplateAttributes
)
}),
+ xmlLanguage.data.of({ autocomplete: completeResources }),
+ javascriptLanguage.data.of({ autocomplete: completeResources }),
autoCloseTags,
...javascriptCompletionSupport(jsScope)
])
diff --git a/src/components/Dashboard/Management/DocumentTemplates/DocumentTemplateInfo.jsx b/src/components/Dashboard/Management/DocumentTemplates/DocumentTemplateInfo.jsx
index d78350a8..e9cf6792 100644
--- a/src/components/Dashboard/Management/DocumentTemplates/DocumentTemplateInfo.jsx
+++ b/src/components/Dashboard/Management/DocumentTemplates/DocumentTemplateInfo.jsx
@@ -8,10 +8,12 @@ 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 NoteIcon from '../../../Icons/NoteIcon.jsx'
import AuditLogIcon from '../../../Icons/AuditLogIcon.jsx'
+import FileIcon from '../../../Icons/FileIcon.jsx'
import ObjectForm from '../../common/ObjectForm.jsx'
import EditButtons from '../../common/EditButtons.jsx'
import ObjectTableNavigationButtons from '../../common/ObjectTableNavigationButtons.jsx'
@@ -23,7 +25,10 @@ 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'
const log = loglevel.getLogger('DocumentTemplateInfo')
@@ -40,6 +45,7 @@ const DocumentTemplateInfo = () => {
'DocumentTemplateInfo',
{
info: true,
+ resources: true,
stocks: true,
notes: true,
auditLogs: false
@@ -108,6 +114,7 @@ const DocumentTemplateInfo = () => {
disabled={objectFormState.loading}
items={[
{ key: 'info', label: 'DocumentTemplate Information' },
+ { key: 'resources', label: 'Resources' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
]}
@@ -161,25 +168,27 @@ const DocumentTemplateInfo = () => {
loading={objectFormState.loading}
ref={actionHandlerRef}
>
- }
- active={collapseState.info}
- onToggle={(expanded) => updateCollapseState('info', expanded)}
- collapseKey='info'
+ {
+ setEditFormState((prev) => ({ ...prev, ...state }))
+ }}
>
- {
- setEditFormState((prev) => ({ ...prev, ...state }))
- }}
- >
- {({ loading, isEditing, objectData }) => {
- return (
+ {({ loading, isEditing, objectData }) => (
+
+ }
+ active={collapseState.info}
+ onToggle={(expanded) =>
+ updateCollapseState('info', expanded)
+ }
+ collapseKey='info'
+ >
}
@@ -188,14 +197,35 @@ const DocumentTemplateInfo = () => {
objectData={objectData}
visibleProperties={{
content: false,
- testObject: false
+ testObject: false,
+ resources: false
}}
labelWidth='190px'
/>
- )
- }}
-
-
+
+ }
+ active={collapseState.resources}
+ onToggle={(expanded) =>
+ updateCollapseState('resources', expanded)
+ }
+ collapseKey='resources'
+ >
+
+
+
+ )}
+
{
'EmailTemplateInfo',
{
info: true,
+ resources: true,
attachments: true,
notes: true,
auditLogs: false
@@ -107,6 +108,7 @@ const EmailTemplateInfo = () => {
disabled={objectFormState.loading}
items={[
{ key: 'info', label: 'Email Template Information' },
+ { key: 'resources', label: 'Resources' },
{ key: 'attachments', label: 'Attachments' },
{ key: 'notes', label: 'Notes' },
{ key: 'auditLogs', label: 'Audit Logs' }
@@ -191,11 +193,29 @@ const EmailTemplateInfo = () => {
visibleProperties={{
content: false,
testObject: false,
+ resources: false,
attachments: false
}}
labelWidth='210px'
/>
+ }
+ active={collapseState.resources}
+ onToggle={(expanded) =>
+ updateCollapseState('resources', expanded)
+ }
+ collapseKey='resources'
+ >
+
+
}
diff --git a/src/components/Dashboard/common/CodeBlockEditor.jsx b/src/components/Dashboard/common/CodeBlockEditor.jsx
index ccd8671f..0bb14715 100644
--- a/src/components/Dashboard/common/CodeBlockEditor.jsx
+++ b/src/components/Dashboard/common/CodeBlockEditor.jsx
@@ -166,6 +166,7 @@ export default function CodeBlockEditor({
disabled = false,
minimal = false,
autoCompleteObject = null,
+ resourceNames = [],
errorLines = [],
templateType,
onCursorChange = null
@@ -181,8 +182,14 @@ export default function CodeBlockEditor({
}, [onCursorChange])
const languageExtension = useMemo(
- () => getCodeLanguageExtension(language, autoCompleteObject, templateType),
- [language, autoCompleteObject, templateType]
+ () =>
+ getCodeLanguageExtension(
+ language,
+ autoCompleteObject,
+ templateType,
+ resourceNames
+ ),
+ [language, autoCompleteObject, templateType, resourceNames]
)
const errorLineExtension = useMemo(() => {
@@ -326,6 +333,7 @@ CodeBlockEditor.propTypes = {
disabled: PropTypes.bool,
minimal: PropTypes.bool,
autoCompleteObject: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
+ resourceNames: PropTypes.arrayOf(PropTypes.string),
errorLines: PropTypes.arrayOf(PropTypes.number),
templateType: PropTypes.string,
onCursorChange: PropTypes.func
diff --git a/src/components/Dashboard/common/ObjectProperty.jsx b/src/components/Dashboard/common/ObjectProperty.jsx
index 2fb7f5b2..504b2097 100644
--- a/src/components/Dashboard/common/ObjectProperty.jsx
+++ b/src/components/Dashboard/common/ObjectProperty.jsx
@@ -116,6 +116,7 @@ const ObjectProperty = ({
height = 'auto',
minimal = false,
autoCompleteObject = null,
+ resourceNames = [],
errorLines = [],
templateType,
onCursorChange,
@@ -437,6 +438,7 @@ const ObjectProperty = ({
readOnly={true}
minimal={minimal}
autoCompleteObject={autoCompleteObject}
+ resourceNames={resourceNames}
errorLines={errorLines}
templateType={templateType}
onCursorChange={onCursorChange}
@@ -990,6 +992,7 @@ const ObjectProperty = ({
height={height}
minimal={minimal}
autoCompleteObject={autoCompleteObject}
+ resourceNames={resourceNames}
errorLines={errorLines}
templateType={templateType}
onCursorChange={onCursorChange}
@@ -1145,6 +1148,7 @@ ObjectProperty.propTypes = {
name: PropTypes.string,
language: PropTypes.string,
autoCompleteObject: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
+ resourceNames: PropTypes.arrayOf(PropTypes.string),
errorLines: PropTypes.arrayOf(PropTypes.number),
templateType: PropTypes.string,
onCursorChange: PropTypes.func,
diff --git a/src/components/Dashboard/common/TemplateEditor.jsx b/src/components/Dashboard/common/TemplateEditor.jsx
index f99e6760..7807f3c9 100644
--- a/src/components/Dashboard/common/TemplateEditor.jsx
+++ b/src/components/Dashboard/common/TemplateEditor.jsx
@@ -94,6 +94,16 @@ const parseEjsErrorLines = (message) => {
return [...lines]
}
+const normalizeResourceNames = (resources) => {
+ if (!Array.isArray(resources)) return []
+
+ return resources
+ .map((resource) =>
+ typeof resource === 'string' ? resource : resource?.name
+ )
+ .filter((name) => typeof name === 'string' && name.length > 0)
+}
+
const TemplateEditor = ({
objectData,
template,
@@ -118,11 +128,15 @@ const TemplateEditor = ({
const [errorLines, setErrorLines] = useState([])
const [formatLoading, setFormatLoading] = useState(false)
const [intellisense, setIntellisense] = useState(null)
+ const [responseResources, setResponseResources] = useState(null)
const intellisenseRequestIdRef = useRef(0)
const intellisenseTimerRef = useRef(null)
+ const resourceNames =
+ responseResources ?? normalizeResourceNames(currentTemplate?.resources)
useEffect(() => {
setIntellisense(null)
+ setResponseResources(null)
}, [currentTemplate?._id])
useEffect(() => {
@@ -168,6 +182,9 @@ const TemplateEditor = ({
setIntellisense(result.intellisense)
}
}
+ if (Array.isArray(result?.resources)) {
+ setResponseResources(normalizeResourceNames(result.resources))
+ }
}
)
}, 200)
@@ -369,6 +386,7 @@ const TemplateEditor = ({
autoCompleteObject={
intellisense ?? currentTemplate?.testObject
}
+ resourceNames={resourceNames}
errorLines={errorLines}
templateType={templateType}
onCursorChange={handleCursorChange}
diff --git a/src/components/Dashboard/common/codeBlockEditorUtils.js b/src/components/Dashboard/common/codeBlockEditorUtils.js
index a7bc5493..c0d285f9 100644
--- a/src/components/Dashboard/common/codeBlockEditorUtils.js
+++ b/src/components/Dashboard/common/codeBlockEditorUtils.js
@@ -26,7 +26,8 @@ export function toEditorCode(code, language = 'javascript') {
export function getCodeLanguageExtension(
language = 'javascript',
autoCompleteObject = null,
- templateType
+ templateType,
+ resourceNames = []
) {
const lang = Array.isArray(language) ? language[0] : language
@@ -43,7 +44,11 @@ export function getCodeLanguageExtension(
return xml()
case 'fctemplatelang':
case 'ejs':
- return fcTemplateLang({ autoCompleteObject, templateType })
+ return fcTemplateLang({
+ autoCompleteObject,
+ templateType,
+ resourceNames
+ })
case 'html':
return html()
case 'css':
diff --git a/src/components/Dashboard/context/ApiServerContext.jsx b/src/components/Dashboard/context/ApiServerContext.jsx
index 970c4f11..1109eeb9 100644
--- a/src/components/Dashboard/context/ApiServerContext.jsx
+++ b/src/components/Dashboard/context/ApiServerContext.jsx
@@ -2240,12 +2240,14 @@ const ApiServerProvider = ({ children }) => {
} catch (err) {
const error = getTemplateErrorFromResponse(err)
const intellisense = err?.response?.data?.intellisense
+ const resources = err?.response?.data?.resources
logger.error('Error fetching template intellisense:', error)
const payload = {
error,
...(intellisense != null && typeof intellisense === 'object'
? { intellisense }
- : {})
+ : {}),
+ ...(Array.isArray(resources) ? { resources } : {})
}
if (typeof callback === 'function') {
callback(payload)
diff --git a/src/database/models/DocumentTemplate.js b/src/database/models/DocumentTemplate.js
index d4c8d085..bdc06b77 100644
--- a/src/database/models/DocumentTemplate.js
+++ b/src/database/models/DocumentTemplate.js
@@ -22,6 +22,34 @@ import ListIcon from '../../components/Icons/ListIcon'
import DuplicateIcon from '../../components/Icons/DuplicateIcon'
import BinIcon from '../../components/Icons/BinIcon'
+const computeTemplateResources = (resources) => {
+ if (!Array.isArray(resources)) return []
+
+ const usedNames = new Set()
+ const nextSuffix = new Map()
+
+ return resources.map((resource) => {
+ const filename = resource?.file?.filename || resource?.file?.name || ''
+ const baseName =
+ filename
+ .replace(/\.[^.]*$/, '')
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '') || 'resource'
+ let name = baseName
+ let suffix = nextSuffix.get(baseName) || 1
+
+ while (usedNames.has(name)) {
+ name = `${baseName}-${suffix}`
+ suffix += 1
+ }
+
+ usedNames.add(name)
+ nextSuffix.set(baseName, suffix)
+ return { ...resource, name }
+ })
+}
+
export const DocumentTemplate = {
name: 'documentTemplate',
label: 'Document Template',
@@ -288,6 +316,36 @@ export const DocumentTemplate = {
type: 'codeBlock',
language: 'fcTemplateLang'
},
+ {
+ name: 'resources',
+ label: 'Resources',
+ type: 'objectChildren',
+ required: false,
+ canAddRemove: true,
+ size: 'medium',
+ columns: ['name', 'file'],
+ value: (documentTemplate) =>
+ computeTemplateResources(documentTemplate?.resources),
+ properties: [
+ {
+ name: 'name',
+ label: 'Name',
+ type: 'text',
+ required: true,
+ readOnly: true,
+ columnWidth: 200
+ },
+ {
+ name: 'file',
+ label: 'File',
+ type: 'object',
+ objectType: 'file',
+ required: true,
+ showHyperlink: true,
+ columnWidth: 230
+ }
+ ]
+ },
{
name: 'testObject',
label: 'Test Object',
diff --git a/src/database/models/EmailTemplate.js b/src/database/models/EmailTemplate.js
index 5125ea57..ca5245c3 100644
--- a/src/database/models/EmailTemplate.js
+++ b/src/database/models/EmailTemplate.js
@@ -22,6 +22,34 @@ import EmailTemplateIcon from '../../components/Icons/EmailTemplateIcon'
import DuplicateIcon from '../../components/Icons/DuplicateIcon'
import BinIcon from '../../components/Icons/BinIcon'
+const computeTemplateResources = (resources) => {
+ if (!Array.isArray(resources)) return []
+
+ const usedNames = new Set()
+ const nextSuffix = new Map()
+
+ return resources.map((resource) => {
+ const filename = resource?.file?.filename || resource?.file?.name || ''
+ const baseName =
+ filename
+ .replace(/\.[^.]*$/, '')
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '') || 'resource'
+ let name = baseName
+ let suffix = nextSuffix.get(baseName) || 1
+
+ while (usedNames.has(name)) {
+ name = `${baseName}-${suffix}`
+ suffix += 1
+ }
+
+ usedNames.add(name)
+ nextSuffix.set(baseName, suffix)
+ return { ...resource, name }
+ })
+}
+
export const EmailTemplate = {
name: 'emailTemplate',
label: 'Email Template',
@@ -270,6 +298,36 @@ export const EmailTemplate = {
objectType: (data) => data?.objectType,
showHyperlink: true
},
+ {
+ name: 'resources',
+ label: 'Resources',
+ type: 'objectChildren',
+ required: false,
+ canAddRemove: true,
+ size: 'medium',
+ columns: ['name', 'file'],
+ value: (emailTemplate) =>
+ computeTemplateResources(emailTemplate?.resources),
+ properties: [
+ {
+ name: 'name',
+ label: 'Name',
+ type: 'text',
+ required: true,
+ readOnly: true,
+ columnWidth: 200
+ },
+ {
+ name: 'file',
+ label: 'File',
+ type: 'object',
+ objectType: 'file',
+ required: true,
+ showHyperlink: true,
+ columnWidth: 230
+ }
+ ]
+ },
{
name: 'attachments',
label: 'Attachments',