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