Files
P42_UI/app/views/comms/templates/editor/TemplatesEditorView.js
T
2025-08-27 07:03:09 +00:00

788 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class TemplatesEditorView extends EICDomContent {
constructor(privileges) {
super('/templates', privileges)
Object.assign(this, app.helpers.activeAttributes)
Object.assign(this, app.helpers.basicDialogs)
}
DOMContentLoaded(options) {
for (let model in options.models) this[model] = options.models[model]
this.components = ui.eicfy(this.el)
this.templateId = options.id
this.decodedHtml = null;
this.decodedAltText = null;
(new Tab()).addTabs(this.findAll('menu li'), this.findAll('.template-field'));
this.setupRefs(this.components)
this.checkAccessAndLoadTemplate()
}
async checkAccessAndLoadTemplate() {
const tplEditorContainer = this.find('.tplEditor')
try {
const result = await this.templates.getReadableFolders()
this.authorizedPaths = result.authorizedPaths || []
if (this.authorizedPaths.length === 0) {
tplEditorContainer.innerHTML = `
<div style="padding: 2rem; text-align: center;">
<h2 style="color:#b00020;">Access Denied</h2>
<p>You are not authorized to access the template editor.</p>
</div>
`
return
}
this.loadTemplate()
} catch (err) {
console.error("Error checking template privileges:", err)
tplEditorContainer.innerHTML = `
<div style="padding: 2rem; text-align: center;">
<h2 style="color:#b00020;">Error</h2>
<p>Unable to check permissions due to a system error.</p>
</div>
`
}
}
async loadTemplate() {
const fieldSubj = this.find('.template-field.subject .content')
this.showLoading(fieldSubj)
const tplEditorContainer = this.find('.tplEditor')
this.gridHistory = new DataGrid(this.find('.tpl-history'), {
headers: [{ label: 'Date', type: 'markup', sortable: true }],
actions: []
})
try {
const result = await this.templates.getReadableFolders()
this.authorizedPaths = result.authorizedPaths || []
if (this.authorizedPaths.length === 0) {
tplEditorContainer.innerHTML = `
<div style="padding: 2rem; text-align: center;">
<h2 style="color:#b00020;">Access Denied</h2>
<p>You are not authorized to access the template editor.</p>
</div>
`
return
}
const currentTemplate = await this.templates.get(this.templateId)
if (!currentTemplate) {
ui.growl.append('Template not found. Please select a valid template.')
return
}
const templatePath = currentTemplate.path || '/'
const isAuthorized = this.authorizedPaths.some(authPath => templatePath.startsWith(authPath))
if (!isAuthorized) {
tplEditorContainer.innerHTML = `
<div style="padding: 2rem; text-align: center;">
<h2 style="color:#b00020;">Access Denied</h2>
<p>You do not have permission to access this template.</p>
</div>
`
return
}
this.editedTemplate = new TemplatesModel(currentTemplate)
const templateStatus = currentTemplate.status
if (templateStatus === 'submitted' || templateStatus === 'prod') {
const tplFields = this.findAll('.template-field, .tpl-inputMeta')
tplFields.forEach(field => {
if (field.tagName === 'INPUT' || field.tagName === 'TEXTAREA') {
field.disabled = true
} else {
field.setAttribute('contenteditable', 'false')
}
field.classList.add('disabled')
})
}
if (this.templates.hasPrivilege('edit')) {
this.toggleButton('onTestSend', ['draft', 'submitted'].includes(templateStatus))
this.toggleButton('saveHtml', ['draft', 'created'].includes(templateStatus))
this.toggleButton('onRequestPub', templateStatus === 'draft')
this.toggleButton('onUploadImage', ['draft', 'created'].includes(templateStatus))
this.toggleButton('onPathSearch', ['draft', 'created'].includes(templateStatus))
}
if (this.templates.hasPrivilege('approved')) {
this.toggleButton('onApproved', templateStatus === 'submitted')
this.toggleButton('onRejected', templateStatus === 'submitted')
}
this.currentPath = templatePath
this.find('.tplPath').textContent = this.currentPath
this.decodeHtmlBin(currentTemplate.html, currentTemplate.meta)
this.find('.tplName').value = currentTemplate.name || ' '
const currentDate = new Date()
const oneYearLater = new Date()
oneYearLater.setFullYear(currentDate.getFullYear() + 1)
const startDate = currentTemplate.startDate ? new Date(currentTemplate.startDate) : currentDate
const endDate = currentTemplate.endDate ? new Date(currentTemplate.endDate) : oneYearLater
this.find('.tpl-formattedDateStart').value = this.formatDateToDDMMYYYYHHMM(startDate)
this.find('.tpl-formattedDateEnd').value = this.formatDateToDDMMYYYYHHMM(endDate)
const headerContent = this.extractSection(this.decodedHtml, 'header')
const mainContent = this.extractSection(this.decodedHtml, 'main')
const footerContent = this.extractSection(this.decodedHtml, 'footer')
this.tplSubj = this.find('.template-field.subject')
this.tplHeader = this.find('.template-field.header')
this.tplMain = this.find('.template-field.main')
this.tplFooter = this.find('.template-field.footer')
this.tplAltText = this.find('.template-field.altText')
this.tplSubj.innerHTML = this.replaceMustaches(currentTemplate.meta.subject || ' ')
this.tplHeader.innerHTML = this.replaceMustaches(headerContent || ' ')
this.tplMain.innerHTML = this.replaceMustaches(mainContent || ' ')
this.tplFooter.innerHTML = this.replaceMustaches(footerContent || ' ')
this.tplAltText.innerText = this.decodedAltText || ' '
this.updatePreview()
const tplEditor = this.find('.tplEditor')
const images = await this.templates.getImages()
const tokens = await this.templates.getTechDDTokens(currentTemplate)
const tId = this.templateId
this.htmlEditor = new HtmlEditor(tplEditor, this, {
images,
tokens,
templateStatus,
tId,
externalHandlers: {
onTestSend: this.onTestSend.bind(this),
onUploadImage: this.onUploadImage.bind(this, templateStatus),
onRequestPub: async () => {
if (this.isDialogOpen) return
this.isDialogOpen = true
await this.confirmDialog({
title: 'Request review ?',
message: `You're about to request a review for this template.<br>
During the review process, the template will no longer be editable.
<div class="prompts">
<label small>Remarks or comments for the reviewer:</label>
<textarea name="comments" eictextarea maxlen="500" cols="50"/></textarea>
</div>`,
promptsClass: 'prompts',
okLabel: 'Request review',
severity: 'warning',
okSeverity: 'primary',
okPromise: async (data) => {
try {
await this.saveHtml('submitted', data.comments)
await this.loadTemplate()
return true
} finally {
this.isDialogOpen = false
}
}
}).finally(() => {
this.isDialogOpen = false
})
},
onApproved: async () => {
if (this.isDialogOpen) return
this.isDialogOpen = true
await this.confirmDialog({
title: 'Template approval ?',
message: `Approve this template ?<br>
Remarks or comments for the reviewer:
<div class="prompts">
<textarea name="comments" eictextarea maxlen="500" cols="50"/></textarea>
</div>`,
promptsClass: 'prompts',
okLabel: 'Approve',
severity: 'warning',
okSeverity: 'primary',
okPromise: async (data) => {
try {
await this.saveHtml('prod', data.comments)
await this.loadTemplate()
return true
} finally {
this.isDialogOpen = false
}
}
}).finally(() => {
this.isDialogOpen = false
})
},
onRejected: async () => {
if (this.isDialogOpen) return
this.isDialogOpen = true
await this.confirmDialog({
title: 'Template rejection ?',
message: `FeedBack<br>
<div class="prompts">
<textarea name="comments" eictextarea maxlen="500" cols="50"/></textarea>
</div>
`,
promptsClass: 'prompts',
okLabel: 'Reject',
severity: 'warning',
okSeverity: 'primary',
okPromise: async (data) => {
try {
await this.saveHtml('draft', data.comments)
this.loadTemplate()
return true
} finally {
this.isDialogOpen = false
}
}
}).finally(() => {
this.isDialogOpen = false
})
},
onPathSearch: this.onPathSearch.bind(this),
onStartDateClicked: this.onStartDateClicked.bind(this),
onEndDateClicked: this.onEndDateClicked.bind(this),
saveHtml: this.saveHtml.bind(this, templateStatus)
}
})
// Re-attach tab listeners after reload
if (typeof Tab === 'function') {
(new Tab()).addTabs(this.findAll('menu li'), this.findAll('.template-field'))
}
// Restore the last active tab if available (with a short delay to ensure listeners are attached)
if (typeof this._lastActiveTabIndex === 'number' && this._lastActiveTabIndex >= 0) {
const tabs = Array.from(this.findAll('menu li'))
if (tabs[this._lastActiveTabIndex]) {
setTimeout(() => {
tabs[this._lastActiveTabIndex].click()
// Force toolbar rebuild for the restored tab
const fieldTypes = ['subject', 'header', 'main', 'footer', 'altText', 'preview']
const fieldType = fieldTypes[this._lastActiveTabIndex] || 'subject'
if (this.htmlEditor && typeof this.htmlEditor.buildToolButtons === 'function') {
this.htmlEditor.buildToolButtons(fieldType)
}
}, 150)
}
}
this.gridHistory.clear()
const history = currentTemplate.statusHistory.sort((a, b) => new Date(b.dateTime) - new Date(a.dateTime))
history.forEach(item => {
const statusColor = {
draft: 'info',
created: 'secondary',
submitted: 'warning',
archived: 'primary',
prod: 'success'
}[item.value] || 'danger'
const oldestDate = new Date(item.dateTime).toLocaleDateString('fr-FR')
const fullName = `<span xsmall class="tpl-fullName">By ${item.changedBy.firstname} ${item.changedBy.lastname}</span>`
const status = `<span class="tpl-status" ${statusColor}>${item.value}</span>`
const comment = item.changedBy.comment?.trim()
? `<span xsmall info class="tpl-comment"><u>comment:</u> ${item.changedBy.comment}</span>`
: ''
const details = `
<div eiccard collapsable collapsed>
<span xsmall class="tpl-details">${oldestDate} - ${status}</span>
<div class="collapsible-content">
${fullName}
${comment}
</div>
</div>
`
const row = this.gridHistory.addRow(item.id, [details])
row.addEventListener('click', () => {
const content = row.querySelector('.collapsible-content')
content.style.display = content.style.display === 'none' || content.style.display === '' ? 'block' : 'none'
})
})
} catch (error) {
console.error("Error loading template:", error)
tplEditorContainer.innerHTML = `
<div style="padding: 2rem; text-align: center;">
<h2 style="color:#b00020;">Error</h2>
<p>Unable to load the template due to a system error.</p>
</div>
`
} finally {
this.hideLoading(fieldSubj)
}
const toggleBtn = document.querySelector('.toggle-history')
const historyPanel = document.querySelector('.tpl-history')
toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
historyPanel.classList.toggle('collapsed')
if (historyPanel.classList.contains('collapsed')) {
toggleBtn.classList.remove('icon-close')
toggleBtn.classList.add('icon-history')
} else {
toggleBtn.classList.remove('icon-history')
toggleBtn.classList.add('icon-close')
}
})
document.addEventListener('click', (e) => {
if (!historyPanel.contains(e.target) && !toggleBtn.contains(e.target)) {
historyPanel.classList.add('collapsed')
toggleBtn.classList.remove('icon-close')
toggleBtn.classList.add('icon-history')
}
})
}
updatePreview() {
if (!this.tplHeader || !this.tplMain || !this.tplFooter) return
const previewContent = `
<div class="tpl-preview-block">${this.tplHeader.innerHTML}</div>
<div class="tpl-preview-block">${this.tplMain.innerHTML}</div>
<div class="tpl-preview-block">${this.tplFooter.innerHTML}</div>
`
this.find('.template-field.preview .content').innerHTML = previewContent
}
onStartDateClicked() {
const startDateInput = this.find('.tplStartDate');
startDateInput.showPicker();
this.syncDateInputs(this.find('.tpl-formattedDateStart'), this.find('.tplStartDate'));
}
onEndDateClicked() {
const endDateInput = this.find('.tplEndDate');
endDateInput.showPicker();
this.syncDateInputs(this.find('.tpl-formattedDateEnd'), this.find('.tplEndDate'));
}
syncDateInputs(textInput, dateInput) {
dateInput.addEventListener('input', () => {
textInput.value = this.formatDateToDDMMYYYYHHMM(dateInput.value);
});
textInput.addEventListener('click', () => {
dateInput.showPicker();
});
}
formatDateToDDMMYYYYHHMM(dateString) {
const date = new Date(dateString);
const dateFormatter = new Intl.DateTimeFormat('fr-FR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false // 24h format
});
const formattedDate = dateFormatter.format(date);
return formattedDate;
}
reformatDateToISO(dateInput) {
let dateObj;
if (typeof dateInput === 'string') {
const parts = dateInput.split(/[\s/:]/);
if (parts.length >= 5) {
const [day, month, year, hours, minutes] = parts;
dateObj = new Date(`${year}-${month}-${day}T${hours}:${minutes}:00`);
} else {
console.error('Invalid date format:', dateInput);
return '';
}
} else if (dateInput instanceof Date) {
dateObj = dateInput;
} else {
console.error('Expected a Date object or valid date string but received:', dateInput);
return '';
}
if (isNaN(dateObj.getTime())) {
console.error('Invalid Date object:', dateObj);
return '';
}
const day = String(dateObj.getDate()).padStart(2, '0')
const month = String(dateObj.getMonth() + 1).padStart(2, '0')
const year = dateObj.getFullYear()
const hours = String(dateObj.getHours()).padStart(2, '0')
const minutes = String(dateObj.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day}T${hours}:${minutes}:00.000Z`
}
toggleButton(trigger, isVisible) {
const button = this.components.find(c => c.el.dataset.trigger === trigger)
if (button) {
requestAnimationFrame(() => {
button.el.classList.toggle('hidden', !isVisible)
})
}
}
async onTestSend() {
const button = this.find('.tpl-sendTest')
// Deactivate button and add spinner
this.showLoading(button)
try {
let result = await this.openDialog(
await this.loadContent(
'comms/templates/dialogs/TemplatesMailTestDialog',
{ title: `Send a test mail...` }
)
)
if (result) {
await this.templates.testMail(this.templateId, result.email)
ui.growl.append('Test mail sent successfully', 'success')
}
}
catch (error) {
console.error("Error on dialog UploadImage loading :", error)
ui.growl.append('Error sending test mail', 'error')
} finally {
// Reactivate button
this.hideLoading(button, '<span>Test mail</span>')
}
}
async onUploadImage(templateStatus) {
const button = this.find('.he-uplImg');
const tabs = Array.from(this.findAll('menu li'))
const activeTabIndex = tabs.findIndex(tab => tab.classList.contains('active'))
this._lastActiveTabIndex = activeTabIndex
this.showLoading(button)
let dialogInstance = null
try {
dialogInstance = await this.openDialog(
await this.loadContent(
'comms/templates/dialogs/TemplatesUplImgDialog',
{ title: 'Upload your own image...' }
)
)
if (!dialogInstance) {
return
}
if (dialogInstance.showDialogSpinner) dialogInstance.showDialogSpinner(true)
const response = await this.templates.saveImage(dialogInstance)
if (response?.success) {
ui.growl.append('Image saved successfully', 'success')
await this.saveHtml(templateStatus)
} else {
ui.growl.append('Error saving image', 'error')
}
} catch (error) {
console.error("Error on dialog UploadImage loading:", error)
} finally {
// Always hide the spinner and close dialog if needed
if (dialogInstance?.showDialogSpinner) {
dialogInstance.showDialogSpinner(false)
}
if (dialogInstance?.cancel) {
dialogInstance.cancel()
}
this.hideLoading(button, '<span>Upload image</span>')
}
}
// Decod html binary64 fron DB
decodeHtmlBin(html, meta) {
this.decodedHtml = html ? this.decodeBase64ToUTF8(html) : '';
this.decodedAltText = meta.altText ? this.decodeBase64ToUTF8(meta.altText) : ''
}
decodeBase64ToUTF8(base64Str) {
const binaryString = atob(base64Str)
const uint8Array = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
uint8Array[i] = binaryString.charCodeAt(i)
}
const decoder = new TextDecoder('utf-8')
return decoder.decode(uint8Array)
}
async onPathSearch() {
const button = this.find('.tpl-btn-path')
this.showLoading(button)
try {
let result = await this.templates.getReadableFolders()
if (!result || typeof result !== 'object') {
console.error("getReadableFolders returned invalid result", result)
return
}
let { templates, authorizedPaths, foldersWithFiles, rights } = result
if (!Array.isArray(authorizedPaths)) authorizedPaths = []
if (!authorizedPaths.includes('/templates')) authorizedPaths.unshift('/templates')
this.templates.ffs.authorizedPaths = authorizedPaths
this.templates.ffs.foldersWithFiles = foldersWithFiles || []
this.templates.ffs.rights = rights || []
if (!templates?.templates) {
templates = { templates }
}
const fileList = Array.isArray(templates.templates) ? templates.templates : []
this.templates.ffs.loadStructure(templates || {}, fileList)
this.templates.ffs.currentPath = '/templates'
this.templates.ffs.authorizedPaths = authorizedPaths
this.currentPath = this.find('.tplPath').textContent
let dialogResult = await this.openDialog(
await this.loadContent(
'templates/Ffs/dialogs/FileBrowserDialog',
{ title: `Select a path...` },
{
ffs: this.templates.ffs,
editable: false,
startPath: this.currentPath,
canManage: true,
templates: this.templates,
context: 'templates'
}
)
)
if (dialogResult) {
this.editedTemplate.path = dialogResult
this.find('.tplPath').textContent = dialogResult
}
} catch (error) {
console.error("Error on dialog PathSearch loading :", error)
} finally {
// Reactivate button
this.hideLoading(button)
}
}
extractSection(html, section) {
const regex = new RegExp(`<${section}.*?>([\\s\\S]*?)<\\/${section}>`, 'i')
const match = html.match(regex)
const extractedText = match ? match[1].trim() : ''
const normStr = this.normalizeText(extractedText)
return normStr
}
//Normalize special char. from Word copy/paste
normalizeText(str) {
return str
.replace(/[\u2018\u2019]/g, "'") //Replace by '
.replace(/[\u201C\u201D]/g, '"') // Replace “ ” by "
.replace(/\u00A0/g, ' ') // Replace space
}
toBase64UTF8(str) {
const encoder = new TextEncoder()
const uint8Array = encoder.encode(str)
let binaryString = ''
uint8Array.forEach(byte => {
binaryString += String.fromCharCode(byte)
});
return btoa(binaryString)
}
fromBase64UTF8(base64) {
const binaryString = atob(base64)
const uint8Array = new Uint8Array([...binaryString].map(char => char.charCodeAt(0)))
const decoder = new TextDecoder()
return decoder.decode(uint8Array)
}
async saveHtml(tplStatus, comment = null) {
const button = this.find('.tpl-save')
// Deactivate button and add spinner
this.showLoading(button)
// Save the current active tab index before reload
const tabs = Array.from(this.findAll('menu li'))
const activeTabIndex = tabs.findIndex(tab => tab.classList.contains('active'))
this._lastActiveTabIndex = activeTabIndex
try {
const newTplStatus = (tplStatus === 'created') ? 'draft' : tplStatus
const tplName = this.find('.tplName').value
const tplPath = this.find('.tplPath').textContent
const tplStartDate = this.reformatDateToISO(this.find('.tpl-formattedDateStart').value)
const tplEndDate = this.reformatDateToISO(this.find('.tpl-formattedDateEnd').value)
const curYear = new Date().getFullYear()
const tplType = 'mail'
const path = (tplPath ? (tplPath.startsWith('/') ? tplPath : '/' + tplPath) : '/Common/' + curYear)
// keep all inputs content
const tplSubject = this.cleanInvisibleChars(this.restoreMustaches(this.find('.template-field.subject').innerHTML))
const headerContent = this.cleanInvisibleChars(this.restoreMustaches(this.find('.template-field.header').innerHTML))
const mainContent = this.cleanInvisibleChars(this.restoreMustaches(this.find('.template-field.main').innerHTML))
const footerContent = this.cleanInvisibleChars(this.restoreMustaches(this.find('.template-field.footer').innerHTML))
const altTextContent = this.toBase64UTF8(this.find('.template-field.altText').innerText)
const newHtml = `<header>${headerContent}</header>
<main>${mainContent}</main>
<footer>${footerContent}</footer>`
this.updatePreview()
const refImgIds = this.extractImageIds(newHtml)
const data = {
id: this.templateId,
type: 'templateInfo',
name: tplName,
path: path,
html: this.toBase64UTF8(newHtml),
status: newTplStatus,
meta: {
subject: tplSubject,
type: tplType,
altText: altTextContent,
},
statusHistory: {
changedBy: {
euLoginId: '',
mail: '',
role: '',
comment: comment || '',
},
dateTime: '',
value: newTplStatus
},
startDate: tplStartDate,
endDate: tplEndDate,
imgRefs: refImgIds || '',
comment: comment || '',
}
const response = await this.editedTemplate.save(data)
if (response.success) {
ui.growl.append('Template saved successfully', 'success')
} else {
ui.growl.append('Error saving template', 'error')
}
} catch (error) {
console.error("Error saving template :", error)
} finally {
// Reactivate button
this.hideLoading(button, '<span>Save</span>')
}
}
cleanInvisibleChars(str) {
return str
.replace(/\u200B/g, '') // Unicode invisibles chars
.replace(/&ZeroWidthSpace;/gi, '') // HTML entity for zero-width space
.normalize('NFC') // Normalisation Unicode
.replace(/[\u200B-\u200D\uFEFF]/g, '') // zero-width & BOM
.replace(/&ZeroWidthSpace;/gi, '') // HTML entity
.replace(/[\u2018\u2019\u02BC]/g, "'") // smart apostrophes → '
.replace(/[\u201C\u201D\u00AB\u00BB]/g, '"') // smart quotes → "
.replace(/[\u2013\u2014\u2212]/g, '-') // dashes → -
.replace(/\u2026/g, '...') // ellipsis → ...
.replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g, ' ') // weird spaces → normal space
.replace(/[\u0000-\u001F\u007F]/g, '') // control chars
.replace(/\s{2,}/g, ' ') // double spaces
.replace(/[ \t]+\n/g, '\n') // strip trailing space before newlines
.replace(/\n{3,}/g, '\n\n') // max 2 line breaks
.trim()
}
extractImageIds(htmlContent) {
const tempElement = document.createElement('div')
tempElement.innerHTML = htmlContent
const imgElements = tempElement.querySelectorAll('img')
const ids = [];
imgElements.forEach(img => {
if (img.id) {
ids.push(img.id)
}
})
return ids.join(',')
}
async onDecision(status) {
if (status === 'prod') {
let result = await this.confirmDialog({
title: 'APPROVE this template ?',
message: `<p>This approval will be final.</p>
<p>Are you sure?</p>`,
okLabel: 'Approve',
severity: 'warning',
okPromise: () => this.saveHtml(status, '')
})
if (result) {
ui.growl.append("Template approved !", 'success')
} else {
ui.growl.append("Error approval!", 'error')
}
} else {
let result = await this.openDialog(
await this.loadContent(
'comms/templates/dialogs/TemplatesDecisionDialog',
{ title: `Comment your decision...` }
)
)
if (result) {
await this.saveHtml(status, result.comment)
}
}
}
async addImage(data) {
const response = await this.editedTemplate.saveImage(data)
if (response.success) {
ui.growl.append('Image saved successfully', 'success')
this.loadTemplate(this.templateId)
} else {
ui.growl.append('Error saving image', 'error')
}
}
//Manage tokens mustaches
replaceMustaches(html) {
return html.replace(/\{\{(.*?)\}\}/g, function (match, p1) {
return `<span class="tpl-token" eicchip="" success="" data-original="${p1.trim()}">${p1.trim()}</span>`
});
}
restoreMustaches(html) {
return html.replace(/<span class="tpl-token"[^>]*data-original="(.*?)"[^>]*>.*?<\/span>/g, '{{$1}}')
}
}
app.registerClass('TemplatesEditorView', TemplatesEditorView);