unclean SPARC

This commit is contained in:
STEINNI
2025-08-27 07:03:09 +00:00
commit f308460931
430 changed files with 54426 additions and 0 deletions
@@ -0,0 +1,85 @@
<style>
.mailings{
--tile-width: 320px;
--tile-height: 450px;
--chart-height: 110px;
}
.mailings > header {
background: url('/app/assets/images/cards/mass-mailer.jpg');
}
.mailings .search-bar{
grid-template-columns: auto min-content;
align-items: baseline;
}
.mailings .path label {
white-space: nowrap;
}
.mailings .list {
display: grid;
grid-template-columns: repeat(auto-fill, var(--tile-width));
grid-gap: var(--eicui-base-spacing-m);
}
.mailings .list .tile {
width: var(--tile-width);
height: var(--tile-height);
overflow: visible !important;
}
.mailings .list .tile h1{
max-width: calc(var(--tile-width) - 70px);
}
.mailings .results .filters {
padding: var(--eicui-base-spacing-s) 0;
display: grid;
grid-auto-flow: column;
grid-gap: var(--eicui-base-spacing-m);
width: fit-content;
}
.mailings [eicpiechart] {
height: var(--chart-height);
margin-top: -15px;
}
.mailings .tile .path{
min-height: calc(var(--eicui-base-font-size-2xs)*3);
}
.mailings .results section{
min-height: 6em;
}
.mailings div.results-loader {
width:1em;
display:none;
}
.mailings .error-message {
color: red;
padding: 1em;
font-weight: bold;
text-align: center;
}
.mailings .empty-message {
color: #999;
padding: 1em;
text-align: center;
}
</style>
<article eiccard media class="mailings massMailerDashboard">
<header>
<h1>Mailings Monitoring & Control</h1>
<h2>Communication Services</h2>
</header>
<section>
<div class="cols-2 search-bar">
<select eicselect data-ref="searchCriteria" data-change="onSearchChange" editable data-type="array" placeholder="Search on title, path or user"></select>
<button eicbutton="" xsmall="" rounded="" primary data-trigger="onPathBrowse" icon="icon-folder"><i class="icon-folder"></i></button>
</div>
<article eiccard class="results" data-ref="resultsPane">
<section>
<div class="cols-2 right">
<div class="filters"></div>
<button eicbutton data-trigger="onMailingCreate" primary >Create</button>
</div>
<div class="list" data-output="searchResults" data-async="results"></div>
</section>
</article>
</section>
</article>
@@ -0,0 +1,290 @@
class MailingDashboardView extends EICDomContent {
constructor() {
super()
Object.assign(this, app.helpers.activeAttributes)
this.selectedMailingId = null
this.tileMarkup = app.Assets.Store.html['/app/assets/html/mailing/tile.html']
}
DOMContentLoaded(options) {
for(let model in options.models) this[model] = options.models[model]
const components = ui.eicfy(this.el)
this.setupTriggers(components)
this.setupRefs(components)
this.setupStatusButtons()
this.initializeData()
}
DOMContentFocused(options) {
// Avoid 2nd refesh on DomContentLoaded
if(this.wasBlured){ this.refreshSearch() }
this.wasBlured = false
}
DOMContentBlured(options) { this.wasBlured = true }
async initializeData() {
const result = await this.mailings.getReadableFolders()
this.authorizedPaths = result.authorizedPaths || []
if (this.authorizedPaths.length === 0) {
this.output('searchResults', `<div style="padding: 2rem; text-align: center;">
<h2 style="color:#b00020;">Access Denied</h2>
<p>You're not authorized to access to mailing dashboard</p>
</div>`)
return
}
this.components.searchCriteria.value = [app.User.identity.uuid]
}
setupStatusButtons(){
this.statusButtons = {}
this.statusCounters = {}
for(let status in this.mailings.getStatusLabel()){
this.statusButtons[status] = new Button(null, { rounded: true, size: 'xsmall', severity: 'info', label: `<span>${this.mailings.getStatusLabel()[status]}</span>`})
this.statusButtons[status].click = this.onStatusChange.bind(this, status)
this.statusCounters[status] = new Badge(null, {size: 'xxsmall'})
this.statusButtons[status].el.append(this.statusCounters[status].el)
this.find('.filters').append(this.statusButtons[status].el)
}
}
onStatusChange(status, event){
event.stopPropagation()
event.preventDefault()
this.statusButtons[status].severity = (this.statusButtons[status].severity == 'secondary') ? 'info' : 'secondary'
if(this.getSelectedStatus().length == 0) { // turning off last status resets them all (no-status is meaningless)
for(let status in this.statusButtons) this.statusButtons[status].severity = 'info'
}
this.refreshSearch()
}
onOneStatus(event){
event.stopPropagation()
event.preventDefault()
for(let status in this.statusButtons){
this.statusButtons[status].severity = (status == event.target.dataset.status) ? 'info' : 'secondary'
}
this.refreshSearch()
}
onSearchChange(component, event){
event.stopPropagation()
event.preventDefault()
this.refreshSearch()
}
async refreshSearch(){
this.output('searchResults','')
this.setAsyncLoading(true)
const mailings = await this.mailings.search(this.components.searchCriteria.value, this.getSelectedStatus())
this.setAsyncLoading(false)
this.fillResults(mailings)
}
getSelectedStatus(){
return(
Object.keys(this.mailings.getStatusLabel()).reduce( (acc, status) => {
if(!this.statusButtons[status].el.hasAttribute('secondary')) acc.push(status)
return(acc)
}, [] )
)
}
updateStatusCounts(counts){
for(let status in this.mailings.getStatusLabel()){
this.statusCounters[status].value = (status in counts) ? counts[status] : 0
}
}
fillResults(mailings){
this.output('searchResults', '')
if (!this.authorizedPaths || this.authorizedPaths.length === 0) {
this.output('searchResults', `<div style="padding: 2rem; text-align: center;">
<h2 style="color:#b00020;">Access Denied</h2>
<p>You're not authorized to access to view this mailing</p>
</div>`)
return
}
mailings = mailings.filter(mailing =>
this.authorizedPaths.some(path => mailing.path.startsWith(path))
)
let statusCounts = {}
if (mailings.length === 0) {
this.output('searchResults', `<div class="empty-message">No mailings found for your search and access rights.</div>`)
this.updateStatusCounts(statusCounts)
return
}
for(let mailing of mailings){
if(mailing.status in statusCounts) statusCounts[mailing.status]++
else statusCounts[mailing.status] = 1
const lastStatus = this.mailings.getLatestStatus(mailing)
const dirs = mailing.path.split('/').filter(item=>item)
const pathChips = dirs.map((dir, idx) => {
const path = dirs.slice(0, idx+1).join('/')
return(`<span eicchip xxsmall info data-path="/${path}">${dir}</span>`)
}).join('')
const tile = ui.create(Controller.processTemplate('resultTile',
this.tileMarkup,
{ mid: mailing.id,
name: mailing.name ? mailing.name : 'Untitled',
status: mailing.status,
statusLabel: this.mailings.getStatusLabel()[mailing.status],
path: mailing.path,
pathChips: pathChips,
lastUpdate: (new Intl.DateTimeFormat("fr-FR", {timeZone: "Europe/Paris", dateStyle:'medium', timeStyle:'medium'})).format(new Date(lastStatus.dateTime)), //'23 Aug 2024 16:48:01',
lastContributor: `${lastStatus.changedBy.firstname} ${lastStatus.changedBy.lastname}`,
kpis: {
nbRecipients: mailing.nbRecipients || 0,
nbPending: (mailing.status=='sent') ? mailing.nbRecipients - mailing.kpis.bouncesCount -mailing.kpis.readCount : 'N/A',
nbBounced: (mailing.status=='sent') ? mailing.kpis.bouncesCount : 'N/A',
nbReached: (mailing.status=='sent') ? mailing.kpis.readCount : 'N/A',
},
}
))
if(!mailing.nbRecipients || (mailing.nbRecipients.length==0)) {
tile.querySelector('[data-trigger="onRecipientsList"]').closest('li[menuitem]').setAttribute('disabled','')
}
if(!mailing.kpis.bouncesCount || (mailing.kpis.bouncesCount.length==0)) {
tile.querySelector('[data-trigger="onBouncesList"]').closest('li[menuitem]').setAttribute('disabled','')
}
tile.querySelectorAll('[eicdropdown]').forEach(el => {
const dropDown = new DropDown(el)
dropDown.menu.el.querySelectorAll('[data-trigger]').forEach(el => {
el.addEventListener('click', this[el.dataset.trigger].bind(this, dropDown))
})
})
tile.querySelectorAll('[data-path]').forEach(el => el.addEventListener('click', this.onPathClicked.bind(this)))
tile.querySelectorAll('button[data-trigger]').forEach(el => {
const btn = new Button(el)
btn.click = this[el.dataset.trigger].bind(this, btn)
})
tile.querySelector('[data-trigger="onOneStatus"]').addEventListener('click', this.onOneStatus.bind(this))
//temporary until we have stats & unschedule screens
if(['sent'].includes(mailing.status)) ui.hide(tile.querySelector('[data-trigger="onMailingSelect"]'))
this.outputs.searchResults.append(tile)
if(mailing.status=='sent'){
new PieChart(tile.querySelector('[eicpiechart]'), {
title: '',
height: parseInt(getComputedStyle(document.querySelector('.mailings')).getPropertyValue('--chart-height'))+10,
data: [
{ severity: 'danger', value: mailing.kpis.bouncesCount },
{ severity: 'success', value: mailing.kpis.readCount },
{ severity: 'secondary', value: mailing.nbRecipients-mailing.kpis.bouncesCount-mailing.kpis.readCount }
]
})
} else {
tile.querySelector('[eicpiechart]').innerHTML = ''
}
}
this.updateStatusCounts(statusCounts)
}
onPathClicked(event){
event.stopPropagation()
event.preventDefault()
const criteria = this.components.searchCriteria.value.filter(item => (!item.startsWith('/'))) // Don't filter straight in the component, (dbl-trigger)
this.components.searchCriteria.value = [...criteria, event.target.dataset.path] // push not foreseen in component
}
async onPathBrowse() {
const button = this.find('[data-trigger="onPathBrowse"]')
this.showLoading(button)
let result = await this.mailings.getReadableFolders()
let { mailings, authorizedPaths } = result
if (!Array.isArray(authorizedPaths)) {
authorizedPaths = []
}
if (!authorizedPaths.includes('/mailing')) authorizedPaths.unshift('/mailing')
this.mailings.ffs.authorizedPaths = authorizedPaths
if (!mailings.mailings) {
mailings = { mailings }
}
this.mailings.ffs.loadStructure(mailings || {}, [])
this.mailings.ffs.currentPath = '/mailing'
this.mailings.ffs.authorizedPaths = authorizedPaths
let dialogResult = await this.openDialog(
await this.loadContent(
'templates/Ffs/dialogs/FileBrowserDialog',
{ title: `Select a path...` },
{
ffs: this.mailings.ffs,
mailings: this.mailings.mailings,
editable: false,
canManage: false,
startPath: '/mailing',
}
)
)
if (dialogResult) {
const criteria = this.components.searchCriteria.value.filter(item => (!item.startsWith('/')))
this.components.searchCriteria.value = [...criteria, dialogResult]
this.hideLoading(button)
}
this.hideLoading(button)
}
onMailingSelect(component, event){
event.stopPropagation()
event.preventDefault()
app.Router.route(`/mailings/${component.el.dataset.id}`)
}
async onRecipientsList(component, event){
const mid = event.target.dataset.id
component.button.loading = true
let recipients = await this.mailings.getRecipients(mid)
component.button.loading = false
this.toCSV( // Kick id & meta
recipients.map(row => { const { id, meta , ...filtered } = row; return(filtered) } ),
{ headers: ['Email', 'Source name', 'Source type' ],
filename: `Recipient`
}
)
}
async onBouncesList(component, event){
const mid = event.target.dataset.id
component.button.loading = true
const bounces = await this.mailings.getBounces(mid)
component.button.loading = false
this.toCSV( // Kick id & meta
bounces.map(row => { const { id, ...filtered } = row; return(filtered) } ),
{ headers: ['Email', 'Date bounced' ],
filename: `Bounces`
}
)
}
async onMailingCreate(component, event){
event.stopPropagation()
event.preventDefault()
component.loading = true
const path = this.authorizedPaths?.[0]
const response = await this.mailings.save({
status: "created",
path: path
})
component.loading = false
if(response && response.id) {
app.Router.route(`/mailings/${response.id}`)
}
}
}
app.registerClass('MailingDashboardView', MailingDashboardView)
@@ -0,0 +1,377 @@
<style>
.mailing-sheet > header { background: url('/app/assets/images/cards/mass-mailer.jpg'); }
.mailing-sheet .history { min-width: 320px; }
.mailing-sheet .sheet-content { display: none; }
.mailing-sheet .sheet-content[data-content="template"] section { display: grid; }
.mailing-sheet .sheet-content[data-content="template"] section .email-subject {
padding: var(--eicui-base-spacing-s);
border: 1px solid var(--eicui-base-color-grey-10);
margin: var(--eicui-base-spacing-xs) 0 var(--eicui-base-spacing-m) 0;
}
.mailing-sheet .sheet-content[data-content="template"] section .email-content {
border: 1px solid var(--eicui-base-color-grey-10);
border-top: 2px solid var(--eicui-base-color-grey-75);
padding: var(--eicui-base-spacing-m);
display: grid;
width: 1fr;
overflow: auto;
max-height: 70vh;
}
.mailing-sheet .sheet-content[data-content="approval"] .pane,
.mailing-sheet .sheet-content[data-content="schedule"] .form {
border: 1px solid var(--eicui-base-color-grey-10);
border-left: 3px solid var(--eicui-base-color-grey-50);
margin: var(--eicui-base-spacing-s) 0;
padding: 0 var(--eicui-base-spacing-m);
}
.mailing-sheet .sheet-content[data-content="schedule"] .form {
min-height: 20vh;
/*
align-content: center;
justify-content: center;
*/
}
.mailing-sheet .metrics { min-width: 160px; text-align: center; }
.mailing-sheet .recipients-grid .row { grid-template-columns: 150px 4fr 1fr 60px 100px; }
.mailing-sheet .recipients-grid .dataset .row .cell:nth-child(2),
.mailing-sheet .recipients-grid .dataset .row .cell:nth-child(4),
.mailing-sheet .recipients-grid .dataset .row .cell:nth-child(5) { text-align: center; }
.mailing-sheet .recipients-grid .cell.actions {
display: grid !important;
justify-content: right;
grid-auto-flow: column;
grid-gap: var(--eicui-base-spacing-xs);
}
.mailing-sheet .workflow { height: 20vh; }
.mailing-sheet .create-path-choice{
grid-template-columns: auto min-content;
align-items:center;
}
.mailing-sheet footer button{
justify-self: center;
}
.mailing-sheet span[info].icon-info{ margin-left:3rem; }
.mailing-sheet .history section{ max-height: 20rem; }
.mailing-sheet button[data-trigger="onDupsInfo"]{ width: min-content; }
.mailing-sheet div[data-output="rejectionReason1"],
.mailing-sheet div[data-output="rejectionReason2"],
.mailing-sheet div[data-output="reviewComments2"]{
background-color: var(--eicui-base-color-grey-20);
padding: 0.5rem;
}
.mailing-sheet [hidden] { display: none; }
</style>
<article eiccard media class="massmailer mailing-sheet">
<header>
<h1 class="mailing-name" data-output="name"></h1>
<h2>Communication Services</h2>
</header>
<section>
<div class="cols-2 left">
<div>
<article eiccard data-async="info">
<header>
<h1>General Information</h1>
</header>
<section>
<alert eicalert small muted info>current status is <span eicchip small info data-output="status"></span></alert>
<div class="cols-2">
<div>
<label xsmall>Created</label>
<span primary data-output="created"></span>
</div>
<div>
<label xsmall>Creator</label>
<span primary data-output="author"></span>
</div>
</div>
<label xsmall>Path</label>
<span primary data-output="infosPath"></span>
<article eiccard>
<section>
<div data-output="eicpiechart"></div>
<div class="cols-4">
<div class="center"><span xlarge><b data-output="recipients">0</b></span><label primary xsmall>recipients</label></div>
<div class="center"><span xlarge secondary><b data-output="pending">0</b></span><label secondary xsmall>pending</label></div>
<div class="center"><span xlarge danger><b data-output="bounced">0</b></span><label danger xsmall>bounced</label></div>
<div class="center"><span xlarge success><b data-output="reached">0</b></span><label success xsmall>opened</label></div>
</div>
</section>
</article>
</section>
<footer>
<div class="cols-1 right">
<button eicbutton danger small data-trigger="onMailingDelete" data-ref="deleteMailing">Delete this mailing</button>
</div>
</footer>
</article>
<article eiccard collapsable class="history" data-async="activity">
<header>
<h1>Latest activity</h1>
</header>
<section>
<ul nonbulleted data-output="history"></ul>
</section>
</article>
</div>
<div>
<div eicstatechart class="workflow" data-async="mainPane"></div>
<article eiccard class="sheet-content async" data-content="start" data-async="start">
<header>
<div class="cols-2 search-bar">
<h1>Initiate your Mailing
<span info class="icon-info" title="Enter the name of your mailing, then choose a place to store it, then hit Save to go to the next step."></span>
</h1>
</div>
</header>
<section>
<label>Name</label>
<input eicinput type="text" data-ref="mailingName" data-change="onNamePathChange"/>
<label>Path</label>
<dic class="cols-2 create-path-choice">
<div data-output="createPath" data-value=""></div>
<button eicbutton small primary data-trigger="onPathBrowse">Browse</button>
</dic>
</section>
<footer>
<button eicbutton medium primary data-trigger="onSaveCreated" data-ref="saveCreated" disabled>Save</button>
</footer>
</article>
<article eiccard class="sheet-content" data-content="template" data-async="template">
<header>
<div class="cols-2 right">
<div>
<h1>Content
<span info class="icon-info" title="Select the content of your mailing amongst the available templates, then proceed to Recipients."></span>
</h1>
<h2>Template name <b data-output="templateName"></b></h2>
</div>
<button eicbutton data-ref="changeTemplate" data-trigger="onTemplateChange">Select</button>
</div>
</header>
<section>
<div xlarge title="Email subject" class="email-subject"><b data-output="templateSubject"></b></div>
<menu eictab class="content-menu">
<li>HTML content</li>
<li>Text (alt.) content</li>
</menu>
<div class="email-content" data-output="templateHtmlBody"></div>
<div class="email-content text" data-output="templateAlternateText"></div>
<div class="cols-2" style="display: none;" >
<article eiccard>
<header>
<h1></h1>
<span class="template-path" eicchip="" secondary="" xsmall="">Current path:</span>
</header>
<section>
<div class="templates-grid"></div>
</section>
</article>
<article eiccard>
<header>
<h1>Template: </h1>
</header>
<section>
</section>
<footer>
<button eicbutton primary disabled data-trigger="onTestSend"><span>Send a test mail</span></button>
</footer>
</article>
</div>
</section>
</article>
<article eiccard class="sheet-content async" data-content="recipients" data-async="recipients">
<header>
<div class="cols-2 ">
<h1>Recipients
<span info class="icon-info" title="Select or import your recipients source(s). Once you have some recipients, you can proceed to the Mapping step."></span>
</h1>
</div>
</header>
<section>
<div class="cols-2 left">
<article eiccard class="metrics">
<header>
<h1>summary</h1>
</header>
<section>
<div>
<div>
<label xsmall><b>Total in sources</b></label>
<span xxxlarge><b data-output="totalRecipients">0</b></span>
</div>
<div>
<label xsmall><b data-output="labelDupes">Duplicates</b></label>
<span xxlarge><b data-output="totalRecipientsDupes">0</b></span>
<button eicbutton secondary rounded xsmall data-ref="dupsInfo" data-trigger="onDupsInfo" title="More info about those duplicates"><i class="icon-search"></i></button>
</div>
<div>
<label xsmall><b data-output="labelExcluded">Total excluded</b></label>
<span xxlarge><b data-output="totalRecipientsExcluded">0</b></span>
</div>
<div>
<label xsmall><b>Total sendable</b></label>
<span xxlarge><b data-output="totalFinalRecipients">0</b></span>
</div>
</div>
</section>
</article>
<article eiccard>
<header>
<div class="cols-2 right" style="grid-template-columns: auto max-content;">
<div>
<h1>Recipients sources</h1>
</div>
<div class="cols-3">
<button eicbutton primary small data-ref="btnFetch" data-trigger="onFetch" hidden><span>Add from MyEIC</span></button>
<button eicbutton primary small data-ref="btnImport" data-trigger="onImport" hidden><span>Add from Excel</span></button>
<button eicbutton primary small warning data-ref="btnExclusion" data-trigger="onExclusion" hidden><span>Add exclusion xls</span></button>
</div>
</div>
</header>
<section>
<div eicdatagrid footer="hidden" class="recipients-grid"></div>
</section>
</article>
</div>
</section>
</article>
<article eiccard class="sheet-content async" data-content="mappings" data-async="mappings">
<header>
<h1>Data mapping
<span info class="icon-info" title="When all variables are mapped for all your recipients sources, you'll be able test and review (or let review) your mailing."></span>
</h1>
</header>
<section data-output="mappingsPanel"></section>
</article>
<article eiccard class="sheet-content async" data-content="approval" data-async="review">
<header>
<h1>Approval</h1>
<h2>Last checks before scheduling your mailing for expedition</h2>
</header>
<section class="approval-panel">
<div class="pane" data-output="testPane">
<label xlarge >Test your content</label>
<div class="cols-2 right middle">
<p>Send a copy of the email content to your mailbox</p>
<button eicbutton primary data-trigger="onTestSend"><span>Send test email...</span></button>
</div>
</div>
<div class="pane" data-output="revieweePane">
<label xlarge >Review</label>
<div data-output="revieweeOngoing">
<p>This mailing is currently being reviewed.</p>
</div>
<div data-output="revieweeApproved">
<div eicalert success>Your mailing has been approved by <b data-output="approUser1"></b> on <b data-output="approDate1"></b></div>
</div>
<div data-output="revieweeRejected">
<div eicalert danger>
<p>Your mailing has been rejected by <b data-output="rejectionUser1"></b> on <b data-output="rejectionDate1"></b></p>
<label medium>Reason</label>
<div small data-output="rejectionReason1"></div>
<p xsmall>Read the above comments, adapt your mailing accordingly and request another review.</p>
<div class="cols-1 center">
<button eicbutton primary data-trigger="onReviewRequest"><span>Request new review</span></button>
</div>
</div>
</div>
<div class="cols-2 right" data-output="revieweeRequest">
<p>In order to schedule the expedition of your mailing, it must be reviewed and approved first.</p>
<button eicbutton primary data-trigger="onReviewRequest"><span>Request review</span></button>
</div>
</div>
<div class="pane" data-output="reviewerPane">
<label xlarge >Review request</label>
<div data-output="reviewerChoice">
<div eicalert info>
<div>Final review requested by <b data-output="requestUser2"></b> on <b data-output="requestDate2"></b></div>
<label medium>Remarks / Comments</label>
<div small data-output="reviewComments2"></div>
</div>
<p xsmall>Please, have a thorough check on email content and recipients before giving the final GO</p>
<div class="cols-2 buttons">
<button eicbutton warning data-trigger="onReviewReject"><span>Request changes (Reject sending)</span></button>
<button eicbutton primary data-trigger="onReviewApprove"><span>Approve sending</span></button>
</div>
</div>
<div data-output="reviewerApproved">
<div eicalert success >Mailing approved by <b data-output="approUser2"></b> on <b data-output="approDate2"></b> </div>
</div>
<div data-output="reviewerRejected">
<div eicalert warning >
<div>Mailing rejected by <b data-output="rejectionUser2"></b> on <b data-output="rejectionDate2"></b></div>
<label medium>Reason:</label>
<div small data-output="rejectionReason2"></div>
</div>
</div>
</div>
</section>
</article>
<article eiccard class="sheet-content async" data-content="schedule" data-async="schedule">
<header>
<h1>Expedition</h1>
<h2></h2>
</header>
<section class="schedule-panel">
<div data-output="toSchedule">
<div eicalert info>
<span>
You are ready to send your mails. Please, choose when you would like to process.<br/>
You may choose to sent it now (mails will be processed within 5 minutes), or to schedule it for a specific date and time.
</span>
</div>
<div class="cols-2 center middle">
<div class="immediate form">
<label large>Send the mailing now</label>
<p>(mails will be processed within 5 minutes)</p>
<input eicinput type="hidden" data-ref="scheduleDateNow" value="${new Date().toISOString().split('T')[0]}" />
<input eicinput type="hidden" data-ref="scheduleTimeNow" value="5min" />
<button eicbutton primary data-ref="sendButton" data-trigger="onSendingNow"><span>Send now</span></button>
</div>
<div class="scheduled form">
<label large>Schedule sending for later</label>
<div class="cols-2">
<div>
<label small>Date:</label>
<input eicinput type="date" data-ref="scheduleDateLater" min="${new Date().toISOString().split('T')[0]}" value="${new Date().toISOString().split('T')[0]}" data-change="onScheduleDateChange" >
</div>
<div>
<label small>Time:</label>
<select eicselect name="scheduleTime" data-ref="scheduleTimeLater" data-change="onScheduleTimeChange">
<option></option>
</select>
</div>
</div>
<button class="schedule" eicbutton disabled primary data-ref="scheduleButton" data-trigger="onSendingLater"><span>Schedule</span></button>
</div>
</div>
<div class="cols-1 center">
<button class="schedule" eicbutton secondary data-trigger="onAbortSending"><span>Abort sending</span></button>
</div>
</div>
<div data-output="scheduled">
<div eicalert info>
<span>
This mailings has been scheduled for <b data-output="scheduledDate"></b>.<br/>
As soon as it is sent, you'll be able to access it's statistics from the mailing dashboard.
</span>
</div>
<div class="cols-1 right" data-output="unschedule">
<button class="right" eicbutton="" warning="" data-trigger="onUnschedule">Unschedule</button>
</div>
</div>
</section>
</article>
</div>
</section>
</article>
@@ -0,0 +1,872 @@
class MailingSheetView extends EICDomContent {
constructor() {
super()
Object.assign(this, app.helpers.activeAttributes)
Object.assign(this, app.helpers.basicDialogs)
Object.assign(this, app.helpers.validators)
}
DOMContentLoaded(options) {
for(let model in options.models) this[model] = options.models[model]
const components = ui.eicfy(this.el)
this.setupTriggers(components)
this.setupRefs(components)
this.currentMailing = {}
this.mailingId = options.id
this.workflow = new NodeMap(this.find('.workflow'), {
resizable: true,
allowDrag: false,
orientation: 'linear',
entity: { width: 120, height: 40, gap: 40 }
})
this.workflow.click = this.onWorkflowSelect.bind(this)
this.recipientsGrid = new DataGrid(this.find('.recipients-grid'), {
headers: [
{label: 'date', type: 'date'},
{label: 'source'},
{label: 'type', type: 'markup'},
{label: '<span small class="icon-user" title="recipients"></span>'}
],
actions:[],
})
this.recipientsGrid.enableFooter = true
this.recipientsGrid.updateFooter = ()=>{} //We'll manage our own footer thx
this.templateShadowRoot = this.outputs.templateHtmlBody.attachShadow({ mode: 'open' })
this.components['tabs'] = new Tab()
this.components['tabs'].addTabs(this.findAll('.content-menu li'), this.findAll('.email-content'))
ui.hide(this.components.deleteMailing.el)
this.fillScheduleTime()
this.loadMailingInfo(this.mailingId )
}
/****************************** Loading & refresh tabs **************************************/
async loadMailingInfo(mailingId) {
this.setAsyncLoading(true)
this.currentMailing = await this.mailings.get(mailingId)
const rawPaths = await this.mailings.getReadableFolders()
const authorizedPaths = Array.isArray(rawPaths) ? rawPaths : (rawPaths?.authorizedPaths || [])
if (!authorizedPaths.includes(this.currentMailing.path)) {
const wrapper = document.querySelector('.massmailer.mailing-sheet section')
if (wrapper) {
wrapper.innerHTML = `
<div style="padding: 2rem; text-align: center;">
<h2 style="color:#b00020;">Access Denied</h2>
<p>You're not authorized to access to view this mailing</p>
</div>`
}
this.setAsyncLoading(false)
return
}
this.refresh()
this.setAsyncLoading(false)
}
async refresh() {
this.workflow.data = app.meta.collections['wf-mailings'] //Clear
if(this.currentMailing.status == 'sent'){
this.components.chart = new PieChart(this.outputs.eicpiechart, { height: 120 })
this.components.chart.data = [
{ severity: 'danger', value: this.currentMailing.kpis.bouncesCount },
{ severity: 'success', value: this.currentMailing.kpis.readCount },
{
severity: 'secondary',
value: this.currentMailing.nbRecipients - this.currentMailing.kpis.bouncesCount - this.currentMailing.kpis.readCount
}
]
//TODO Show stats & graph tab
} else {
this.fillGeneralInfo()
this.fillLatestActivity()
this.refreshCreated()
await this.refreshContent()
this.fillRecipients()
this.refreshMappings()
this.refreshReview()
this.refreshSchedule()
this.refreshWorflow()
}
//this.setupTriggers()
}
fillGeneralInfo(){
this.output('name', this.currentMailing.name)
this.output('created', ui.format.date(this.currentMailing.statusHistory[0].dateTime))
this.output('author', this.currentMailing.statusHistory[0].changedBy.euLoginId)
this.output('status', this.mailings.getStatusLabel()[this.currentMailing.status])
this.output('infosPath', this.pathChips(this.currentMailing.path))
this.output('recipients', this.currentMailing.nbRecipients)
this.output('pending', (this.currentMailing.nbRecipients>0) ? this.currentMailing.nbRecipients - this.currentMailing.kpis.bouncesCount - this.currentMailing.kpis.readCount : 'N/A')
this.output('bounced', this.currentMailing.kpis.bouncesCount || 'N/A')
this.output('reached', this.currentMailing.kpis.readCount || 'N/A')
if(['created','draft'].includes(this.currentMailing.status)){
ui.show(this.components.deleteMailing.el)
} else {
ui.hide(this.components.deleteMailing.el)
}
}
fillLatestActivity(){
this.output('history', '')
let prevstatus = ''
for(let historyEntry of [...this.currentMailing.statusHistory].reverse()){
let action
if((historyEntry.value=='draft') && (prevstatus!= 'rejected')) { action = 'Draft saved' }
else action = this.mailings.getStatusLabel()[historyEntry.value]
let severity
switch(historyEntry.value) {
case 'rejected': severity = 'danger'; break
case 'approved': severity = 'success'; break
case 'submitted': severity = 'warning'; break
case 'sent': severity = 'primary'; break
default : severity = 'info'; break
}
let markup = `<li>
<div eicalert muted ${severity} xsmall>
${action}: <b>${ (new Intl.DateTimeFormat("fr-FR", {timeZone: "Europe/Paris", dateStyle:'medium', timeStyle:'medium'})).format(new Date(historyEntry.dateTime))}</b><br>
by <b>${historyEntry.changedBy.firstname} ${historyEntry.changedBy.lastname}</b>
</div></li>`
prevstatus = historyEntry.value
this.outputs.history.append(ui.create(markup))
}
}
refreshCreated(){
this.components.saveCreated.label = (this.currentMailing.status=='created') ? 'Save' : 'Change'
this.components.saveCreated.severity = (this.currentMailing.status=='created') ? 'primary' : 'secondary'
this.components.mailingName.value = this.currentMailing.name
this.outputs.createPath.dataset.value = this.currentMailing.path
this.outputs.createPath.innerHTML = this.pathChips(this.currentMailing.path)
this.onNamePathChange()
}
async refreshContent(){
if(this.currentMailing.template.id) {
this.setAsyncLoading(true, ['mainPane'])
let templateInfo = await this.templates.get(this.currentMailing.template.id)
this.setAsyncLoading(false, ['mainPane'])
if(templateInfo) {
this.currentMailing.template = templateInfo
this.fillTemplate(templateInfo);
}
}
this.components.changeTemplate.severity = (this.currentMailing.template.id) ? 'secondary' : 'primary'
}
refreshWorflow() {
const stepActivations = this.mailings.getStepActivations(this.currentMailing, this.templates.getTokens(this.currentMailing.template))
this.workflow.data = app.meta.collections['wf-mailings']
for(const entity of this.workflow.entities){
entity.disabled = stepActivations[entity.id].disabled
entity.severity = stepActivations[entity.id].severity
}
let entity = null
if(this.currentMailing.status!='scheduled'){ entity = this.workflow.entities.find(e => e.severity == 'warning') }
else { entity = this.workflow.entities.find(e => e.id == 'schedule') }
this.onWorkflowSelect(entity)
}
fillTemplate(templateInfo){
const filterText = (text) => {
let element = document.createElement('div')
element.textContent = text
text = element.innerHTML
text = text.length > 100 ? text.slice(0, 100) + '...' : text
return text
}
let subject = filterText(templateInfo.meta.subject ? templateInfo.meta.subject : '')
this.output('templateSubject', subject.replace(this.validators.mustacheTokenRegex,'<span class="token" eicchip success>$1</span>'))
let html = atob(templateInfo.html).replace(this.validators.mustacheTokenRegex,'<span class="token" eicchip success>$1</span>')
this.templateShadowRoot.innerHTML = `<style>
span.token[eicchip]{
border-radius: 60px !important;
background-color: var(--eicui-base-color-success-100);
color: var(--eicui-base-color-white);
font-size: 0.75rem;
padding: var(--eicui-base-spacing-2xs) var(--eicui-base-spacing-s);
min-height: var(--eicui-base-spacing-xl);
display: inline-grid;
grid-template-columns: min-content min-content min-content;
min-height: 0;
align-items: center;
margin: 1px var(--eicui-base-spacing-2xs);
pointer-events: all;
white-space: nowrap;
position: relative;
text-shadow: none;
}
</style>\n${html}`
let altText = filterText(templateInfo.meta.altText ? atob(templateInfo.meta.altText) : '')
this.output('templateAlternateText', `<pre>${altText.replace(this.validators.mustacheTokenRegex,'<span class="token" eicchip success>$1</span>')}</pre>`)
this.output('templateName', templateInfo.name.length > 60 ? templateInfo.name.slice(0, 60) + '...' : templateInfo.name)
this.outputs['templateName'].setAttribute('title', templateInfo.name)
}
fillRecipients() {
this.recipientsGrid.clear()
let totalUnfilteredRecipients = 0
if(this.mailings.canImport()) { ui.show(this.components.btnImport.el, 'inline-flex') }
else { ui.hide(this.components.btnImport.el) }
if(this.mailings.canImportExclusion){ ui.show(this.components.btnExclusion.el, 'inline-flex') }
else{ui.hide(this.components.btnExclusion.el) }
if(this.mailings.canFetch()) { ui.show(this.components.btnFetch.el, 'inline-flex') }
else { ui.hide(this.components.btnFetch.el) }
for(const emailSource of this.currentMailing.sources){
const oneImport = this.currentMailing.imports.find(i => i.sourceId==emailSource.id)
if(!emailSource.negative) totalUnfilteredRecipients += oneImport.data.length
let sourceType = (emailSource.sourceType=='Excel') ? `<span class="icon-xls" title="Imported Excel" ${emailSource.negative ? 'warning':''}></span>` : `<span class="icon-market" title="Database query (${emailSource.sourceType})"></span>`
if(emailSource.negative) sourceType = `<span eicbadge="" warning="" xsmall="" class="icon-deny" title="Exclusion list"></span>${sourceType}`
let row = this.recipientsGrid.addRow(emailSource.id, [
oneImport.importDate, // expecting source import datestamp
emailSource.sourceName,
sourceType,
oneImport.data.length
], true)
if(emailSource.sourceType!='Excel'){
// let btnEditQC = new Button(null,{ icon: 'icon-edit', severity: 'primary', size: 'xsmall', hint: 'Change the query'})
// btnEditQC.click = this.onSourceEditQC.bind(this, oneImport.id )
// row.querySelector('.cell.actions').appendChild(btnEditQC.el)
let btnRefreshQC = new Button(null,{ icon: 'icon-refresh', severity: 'primary', size: 'xsmall', hint: 'Refresh data by replaying the query now'})
btnRefreshQC.click = this.onSourceRefreshQC.bind(this, oneImport.id, btnRefreshQC )
row.querySelector('.cell.actions').appendChild(btnRefreshQC.el)
}
let btnDel = new Button(null,{ icon: 'icon-bin', severity: 'danger', size: 'xsmall', hint: 'Remove'})
btnDel.click = this.onSourceDel.bind(this, oneImport.id )
row.querySelector('.cell.actions').appendChild(btnDel.el)
}
this.recipientsGrid.filter() // because skipfilter = true because potentially Big Xls...
this.recipientsGrid.footer.classList.add('row')
this.recipientsGrid.footer.innerHTML = `
<div class="cell"></div>
<div class="cell"></div>
<div class="cell" title="Total recipients"><i>Total sendable recipients</i></div>
<div class="cell" title=""></div>
<div class="cell" title="${this.currentMailing.nbRecipients}"><i>${this.currentMailing.nbRecipients}</i></div>
<div class="cell actions"></div>
`
//this.currentMailing.nbRecipients = totalRecipients
const dupes = this.mailings.findDupes(this.currentMailing.imports)
this.output('totalRecipients', totalUnfilteredRecipients)
this.output('totalRecipientsExcluded', totalUnfilteredRecipients-this.currentMailing.nbRecipients)
if(totalUnfilteredRecipients-this.currentMailing.nbRecipients>0) {
this.outputs.totalRecipientsExcluded.setAttribute('warning','')
this.outputs.labelExcluded.setAttribute('warning','')
} else {
this.outputs.totalRecipientsExcluded.removeAttribute('warning')
this.outputs.labelExcluded.removeAttribute('warning')
}
this.output('totalFinalRecipients', this.currentMailing.nbRecipients)
this.output('totalRecipientsDupes', Object.keys(dupes).length)
if(Object.keys(dupes).length>0){
this.outputs.totalRecipients.parent
this.outputs.totalRecipientsDupes.setAttribute('danger','')
this.outputs.labelDupes.setAttribute('danger','')
this.components.dupsInfo.dupes = dupes //hell yeah
ui.show(this.components.dupsInfo.el, 'inline-flex')
} else {
this.outputs.totalRecipientsDupes.removeAttribute('danger')
this.outputs.labelDupes.removeAttribute('danger')
this.components.dupsInfo.dupes = null
ui.hide(this.components.dupsInfo.el)
}
}
refreshMappings() {
if((!this.currentMailing.template) || (!this.currentMailing.template.id)) return
const templateTokens = this.templates.getTokens(this.currentMailing.template)
this.output('mappingsPanel', '')
const nbMapMissingPerSrc = this.mailings.nbMapMissingPerSrc(this.currentMailing, templateTokens)
for(let source of this.currentMailing.sources){
if(source.negative) continue
const sourceType = (source.sourceType=='Excel') ? `<span class="icon-table" title="Imported Excel"></span>` : `<span class="icon-market" title="Database query (${source.sourceType})"></span>`
let article = ui.create(`<div>
<article eiccard collapsable ${nbMapMissingPerSrc[source.id] > 0 ? '': 'collapsed' }>
<header>
<h1>${sourceType} ${source.sourceName}</h1>
<h2>${nbMapMissingPerSrc[source.id] > 0 ? `<span eicchip xsmall warning>${nbMapMissingPerSrc[source.id]} missing</span>`: '<span eicchip small success>complete</span>' } </h2>
</header>
<section>
<ul nonbulleted>
<li class="cols-2">
<label large>Content variable</label>
<label large>Recipient data</label>
</li>
</ul>
</section>
</article></div>
`)
let oneMapping = this.currentMailing.mappings.find(i => i.sourceId==source.id)
if(!oneMapping){
oneMapping = {
"sourceName": source.sourceName,
"sourceType": source.sourceType,
"mappings": { },
"sourceId": source.id,
}
this.currentMailing.mappings.push(oneMapping)
}
let ul = article.querySelector('section ul')
for(let variable of templateTokens) {
let severity, label, title
if(variable in oneMapping.mappings) {
severity = 'secondary'
label = oneMapping.mappings[variable].hint
title = oneMapping.mappings[variable].hint
} else {
severity = 'primary'
label = 'Select...'
title = ''
}
let li = ui.create(`<li class="cols-2 middle">
<div small><b>${variable}</b></div>
<div>
<button eicbutton small ${severity} rounded data-token="${variable}" data-sourceid="${source.id}" data-trigger="onMappingChange" title="${title}">
<span>${label}</span>
</button>
</div>
</li>`)
ul.appendChild(li)
}
let components = ui.eicfy(article)
this.setupTriggers(components)
this.outputs.mappingsPanel.append(article)
}
}
refreshReview(){
const [block2display, contents] = this.mailings.getReviewBlocks(this.currentMailing)
const allBlocks = ['revieweePane','reviewerPane',
'revieweeOngoing','revieweeApproved','revieweeRejected','revieweeRequest',
'reviewerChoice','reviewerApproved','reviewerRejected']
allBlocks.forEach(blockName => ui.hide(this.outputs[blockName]))
block2display.forEach(blockName => ui.show(this.outputs[blockName], 'grid'))
for(let blockName in contents) this.output(blockName, contents[blockName])
}
refreshSchedule(){
if(this.currentMailing.status=='scheduled'){
ui.hide(this.outputs.toSchedule)
ui.show(this.outputs.scheduled)
const scheduledStatus = this.mailings.getLatestStatus(this.currentMailing,'scheduled')
const scheduleDate = (new Intl.DateTimeFormat("en-GB", {timeZone: "Europe/Paris", dateStyle:'medium', timeStyle:'medium'}))
.format(new Date(scheduledStatus.meta.scheduleDate))
this.output('scheduledDate', scheduleDate)
if(app.User.roles.includes('MAIL_Sender')) ui.show(this.outputs.unschedule, 'grid')
else ui.hide(this.outputs.unschedule)
} else {
ui.hide(this.outputs.scheduled)
ui.show(this.outputs.toSchedule)
}
}
/**************************************** Event handlers ******************************************/
async onPathBrowse() {
const button = this.find('button[data-trigger="onPathBrowse"]')
this.showLoading(button)
try {
let result = await this.mailings.getReadableFolders()
if (!result || typeof result !== 'object') {
console.error("getReadableFolders returned invalid result", result)
return
}
let { mailings, authorizedPaths, foldersWithFiles, rights } = result
if (!Array.isArray(authorizedPaths)) authorizedPaths = []
if (!authorizedPaths.includes('/mailing')) authorizedPaths.unshift('/mailing')
this.mailings.ffs.authorizedPaths = authorizedPaths;
this.mailings.ffs.foldersWithFiles = foldersWithFiles || []
if (!mailings?.mailings) {
mailings = { mailings }
}
const fileList = Array.isArray(mailings.mailings) ? mailings.mailings : []
this.mailings.ffs.loadStructure({ mailing: mailings.mailings || mailings }, fileList)
this.mailings.ffs.currentPath = '/mailing'
this.mailings.ffs.authorizedPaths = authorizedPaths
this.mailings.ffs.rights = rights || []
this.createPath = this.find('div[data-output="createPath"]')
this.spanPath = this.createPath ? this.createPath.querySelector('span[data-path]') : null
this.currentPath = this.spanPath ? this.spanPath.getAttribute('data-path') : '/mailing'
let dialogResult = await this.openDialog(
await this.loadContent(
'templates/Ffs/dialogs/FileBrowserDialog',
{ title: `Select a path...` },
{
ffs: this.mailings.ffs,
editable: false,
startPath: this.currentPath,
canManage: true,
mailings: this.mailings,
context: 'mailings'
}
)
)
if (dialogResult) {
this.currentMailing.path = dialogResult
this.output('createPath', this.pathChips(this.currentMailing.path))
this.outputs.createPath.dataset.value = dialogResult
this.onNamePathChange()
}
} catch (error) {
console.error("Error on dialog PathSearch loading :", error)
} finally {
this.hideLoading(button);
}
}
onNamePathChange(){
this.components.saveCreated.disabled = ((this.components.mailingName.value=='') || (this.outputs.createPath.dataset.value ==''))
}
async onSaveCreated(component, event){
this.currentMailing.name = this.components.mailingName.value
this.currentMailing.path = this.outputs.createPath.dataset.value
this.currentMailing.status = 'draft'
this.components.saveCreated.loading = true
try {
await this.mailings.save(this.currentMailing)
ui.growl.append('Mailing saved !', 'success',3000)
} catch(e) {}
this.components.saveCreated.loading = false
this.loadMailingInfo(this.currentMailing.id)
}
onWorkflowSelect(entity) {
if(!entity) return
this.findAll('.sheet-content').forEach(el => {ui.hide(el)})
let target = entity.el.dataset.id
let tab = this.find(`[data-content="${target}"]`)
const stepActivations = this.mailings.getStepActivations(this.currentMailing, this.templates.getTokens(this.currentMailing.template))
if(stepActivations[target].disabled) return // Maybe this is the logical next step, brought automatically, but your role doesn't allow for it.
if(tab) {
ui.show(tab)
switch(target){
case 'template':
// todo display content corresponding to status/role
break
case 'recipients':
// todo display content corresponding to status/role
break
case 'mappings':
// todo display content corresponding to status/role
break
case 'approval':
// todo display content corresponding to status/role
break
case 'schedule':
// todo display content corresponding to status/role
//if(!this.mailings.hasPrivilege('schedule')) return
break
default:
}
} else {
ui.growl.append('Content unavailable', 'danger')
}
}
async onTemplateChange() {
const templateTokens = this.templates.getTokens(this.currentMailing.template)
if(templateTokens && (templateTokens.length>0) && (this.currentMailing.mappings.length>0)){
let result = await this.confirmDialog({
title: `CAUTION: Existing mappings...`,
message: `
<p>You have existing mappings for the current Template.<br>
If you do select a new template (after previewing it),<br>
<b>these mappings will be lost !</b></p>`,
okLabel: 'I understand',
severity: 'warning',
})
if(!result) return
}
let result = await this.openDialog(
await this.loadContent(
'comms/mailings/sheet/dialogs/MailingTemplatePreviewDialog',
{ title: `Pick your content template` },
{ status: ['prod'],
currentMailing: this.currentMailing,
templateModel: this.templates,
mailingModel: this.mailings,
}
)
)
if(result) this.loadMailingInfo(this.currentMailing.id)
}
async onSourceDel(importId, event) {
event.preventDefault()
event.stopPropagation()
const oneImport = this.currentMailing.imports.find(i => i.id==importId)
let result = await this.confirmDialog({
title: (!oneImport.negative) ? `Remove these ${oneImport.data.length} recipients ?` : `Remove this exclusion list and re-allow ${oneImport.data.length} emails ?`,
message: `<p>Are you sure ?</p>`,
okLabel: 'Delete',
severity: 'danger',
okPromise: data => this.mailings.deleteImport(this.currentMailing.id, importId)
})
if(result) this.loadMailingInfo(this.currentMailing.id)
}
async onSourceRefreshQC(importId, btn, event) {
event.preventDefault()
event.stopPropagation()
btn.loading = true
const curImport = this.currentMailing.imports.find(i => i.id==importId)
const curSource = this.currentMailing.sources.find(i => i.mli_id==importId)
const tmpResults = await this.contactMgr.getSample(curSource.query_id, curSource.query_params)
console.log('=====>tmpResults', tmpResults)
let result = await this.confirmDialog({
title: `
`,
message: `<p>You are about to re-run the query !<br>
Currenly you have <b>${curImport.data.length} recipients</b>.<br>
After re-running the query, you would have <b>${tmpResults.nbRows} recipients</b>.</p>
<p>Update the data?</p>`,
okLabel: 'Yes, rerun the query !',
severity: 'warning',
okPromise: data => this.mailings.refreshImport(this.currentMailing.id, {
sourceType: curImport.sourceType,
refreshOnly: true,
importId: curImport.id,
}, importId).then(
() => btn.loading = false,
() => btn.loading = false
)
})
if(result) this.loadMailingInfo(this.currentMailing.id)
btn.loading = false
}
// async onSourceEditQC(importId, event) {
// event.preventDefault()
// event.stopPropagation()
// const curImport = this.currentMailing.imports.find(i => i.id==importId)
// console.log('=====>onSourceEditQC:', curImport.data.length)
// }
async onImport(){
let result = await this.openDialog(
await this.loadContent(
'comms/mailings/sheet/dialogs/MailingImportDialog',
{ title: `Import recipients from Excel file...` },
{ model: this.mailings,
mid: this.currentMailing.id,
}
)
)
if(result) this.loadMailingInfo(this.currentMailing.id)
}
async onExclusion(){
let result = await this.openDialog(
await this.loadContent(
'comms/mailings/sheet/dialogs/MailingExclusionDialog',
{ title: `Import exclusion list from Excel file...` },
{ model: this.mailings,
mid: this.currentMailing.id,
}
)
)
if(result) this.loadMailingInfo(this.currentMailing.id)
}
async onFetch(){
let result = await this.openDialog(
await this.loadContent(
'comms/mailings/sheet/dialogs/MailingFetchDialog',
{ title: `Fetch recipients from MyEIC applications...` },
{
models: {
contacts: this.contactMgr,
mailings: this.mailings,
},
mid: this.currentMailing.id,
}
)
)
if(result) this.loadMailingInfo(this.currentMailing.id)
}
async onDupsInfo(component, event) {
let result = await this.openDialog(
await this.loadContent(
'comms/mailings/sheet/dialogs/MailingDuplicatesDialog',
{ title: `Duplicate emails...` },
{ dupes: component.dupes,
}
)
)
}
async onMappingChange(component, event) {
let result = await this.openDialog(
await this.loadContent(
'comms/mailings/sheet/dialogs/MailingMappingDialog',
{ title: `Link template variable to a column in the Excel file...` },
{
model: this.mailings,
token: component._el.dataset.token,
sourceId: component._el.dataset.sourceid,
currentMailing: this.currentMailing,
}
)
)
if(result) this.loadMailingInfo(this.currentMailing.id)
}
async onTestSend(){
let result = await this.openDialog(
await this.loadContent(
'comms/mailings/sheet/dialogs/MailingTestDialog',
{ title: `Sending a test mail...` },
{ tokens: this.templates.getTokens(this.currentMailing.template) }
)
)
if(result){
await this.mailings.test(this.currentMailing.id, this.currentMailing.template.id, result.email, result.tokenValues)
}
}
async onReviewRequest(){
let result = await this.confirmDialog({
title: 'Request review ?',
message: `You're about to request for this mailing to be reviewed.<br>
During the review process, the mailing won't be editable anymore.
<div class="prompts">
<label small>Remarks / 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: (data) => {
this.currentMailing.status = 'submitted'
this.currentMailing.statusMeta = { comments: data.comments }
return(this.mailings.save(this.currentMailing))
}
})
if(result) { this.loadMailingInfo(this.currentMailing.id) }
}
async onReviewApprove() {
let result = await this.confirmDialog({
title: 'Approve mailing ?',
message: `Approve this mailing for sending ?`,
okLabel: 'Approve',
severity: 'success',
okSeverity: 'primary',
okPromise: (data) => {
this.currentMailing.status = 'approved'
return(this.mailings.save(this.currentMailing))
}
})
if(result) { this.loadMailingInfo(this.currentMailing.id) }
}
async onReviewReject(){
let result = await this.confirmDialog({
title: 'Reject mailing',
message: `Reject sending of this mailing ?
<div class="prompts">
<label small>Changes requested / Reason for rejection:</label>
<textarea name="reason" eictextarea maxlen="500" cols="50"/></textarea>
</div>
`,
promptsClass: 'prompts',
okLabel: 'Reject sending',
severity: 'warning',
okSeverity: 'primary',
okPromise: (data) => {
this.currentMailing.status = 'rejected'
this.currentMailing.statusMeta = { reason: data.reason }
return(this.mailings.save(this.currentMailing))
}
})
if(result) { this.loadMailingInfo(this.currentMailing.id) }
}
onScheduleDateChange(){
this.components.scheduleTimeLater.value = ''
this.fillScheduleTime()
this.components.scheduleButton.disabled = true
}
onScheduleTimeChange() { this.components.scheduleButton.disabled = (this.components.scheduleTimeLater.value == '') }
fillScheduleTime(){
const today = new Date().toISOString().split('T')[0]
const thisHour = (new Date()).getHours()
this.components.scheduleTimeLater.el.innerHTML=''
if(this.components.scheduleDateLater.value==today){
if((thisHour+1) < 24) {
for(let h=thisHour+1; h<24; h++) this.components.scheduleTimeLater.el.append(ui.create(`<option value="${h}">${h.toString().padStart(2, '0')}:00 (${h < 12 ? 'AM' : 'PM'})</option>`))
}
} else {
for(let h=0; h<24; h++) this.components.scheduleTimeLater.el.append(ui.create(`<option value="${h}">${h.toString().padStart(2, '0')}:00 (${h < 12 ? 'AM' : 'PM'})</option>`))
}
}
async onSendingNow(component, event) { await this.sendMailing(this.components.scheduleDateNow.value, this.components.scheduleTimeNow.value) }
async onSendingLater(component, event) { await this.sendMailing(this.components.scheduleDateLater.value, this.components.scheduleTimeLater.value) }
async sendMailing(scheduleDate, scheduleTime) {
const getCETOffset = () => {
const date = new Date()
const options = { timeZone: "Europe/Paris", timeZoneName: "short" }
const formatter = new Intl.DateTimeFormat("en-US", options)
const parts = formatter.formatToParts(date)
const timeZoneOffset = parts.find(part => part.type === "timeZoneName").value
return timeZoneOffset.includes("CEST") ? 2 : 1
}
const today = new Date().toISOString().split('T')[0]
const [year, month, day] = scheduleDate.split("-")
const europeanDate = `${day}/${month}/${year}`
let when
if(scheduleDate==today){
when = scheduleTime=='5min' ? 'in five minutes' : `today at ${scheduleTime.toString().padStart(2, '0')}:00 (${scheduleTime < 12 ? 'AM' : 'PM'})`
} else {
when = `on ${europeanDate} at ${scheduleTime.toString().padStart(2, '0')}:00 (${scheduleTime < 12 ? 'AM' : 'PM'})`
}
let UTCScheduleTime
if(scheduleTime!='5min'){
UTCScheduleTime = (new Date(`${scheduleDate}T${scheduleTime.toString().padStart(2, '0')}:00:00+0${getCETOffset()}:00`)).toISOString()
} else {
UTCScheduleTime = (new Date((new Date).getTime() + 5 * 60 * 1000)).toISOString()
}
let result = await this.confirmDialog({
title: 'Send mailing ?',
message: `You are about to schedule the sending of this mailing ${when}`,
okLabel: 'Schedule sending',
severity: 'secondary',
okSeverity: 'danger',
okPromise: (data) => {
return(this.mailings.schedule(this.currentMailing.id, UTCScheduleTime))
}
})
if(result) { this.loadMailingInfo(this.currentMailing.id); }
}
async onAbortSending(component, event) {
let result = await this.confirmDialog({
title: 'Abort the sending ?',
message: `You are about to <b>abort the sending of this mailing !</b><br>
If you proceed, it will be editable again, <br>
and it will have to be reviewed again.<br>
Is this really what you want to do ?`,
okLabel: 'Abort',
severity: 'warning',
cancelSeverity: 'primary',
okSeverity: 'secondary',
okPromise: (data) => {
this.currentMailing.status = 'draft'
return(this.mailings.save(this.currentMailing))
}
})
if(result) { this.loadMailingInfo(this.currentMailing.id) }
}
async onUnschedule(component, event) {
let result = await this.confirmDialog({
title: 'Unschedule the sending ?',
message: `You are about to <b>unschedule this mailing !</b><br>
If you proceed, it will not be sent, <br>
and you will be able to re-schedule or to abort it.<br>
Is this really what you want to do ?`,
okLabel: 'Unschedule',
severity: 'warning',
cancelSeverity: 'primary',
okSeverity: 'secondary',
okPromise: (data) => {
this.currentMailing.status = 'approved'
return(this.mailings.save(this.currentMailing))
}
})
if(result) { this.loadMailingInfo(this.currentMailing.id) }
}
async onMailingDelete(component, event) {
let result = await this.confirmDialog({
title: 'DELETE this mailing ?',
message: `You are about to <b>DELETE this mailing !</b><br>
If you proceed, it will not be sent, <br>
and everything it contains will be lost.<br>
Is this really what you want to do ?`,
okLabel: 'Delete',
severity: 'danger',
cancelSeverity: 'primary',
okSeverity: 'secondary',
okPromise: (data) => {
return(this.mailings.delete(this.currentMailing.id))
}
})
if(result) { app.Router.route(`/mailings`) }
}
pathChips(path){
const dirs = path.split('/').filter(item=>item)
const markup = (dirs.length>0) ? dirs.map((dir, idx) => {
const path = dirs.slice(0, idx+1).join('/')
return(`<span eicchip xxsmall info data-path="/${path}">${dir}</span>`)
}).join('') : '<span small secondary>[Please select it]</span>'
return(markup)
}
}
app.registerClass('MailingSheetView', MailingSheetView)
/*
TODO
-> Delete Draft (add in WF, add method in MT, add here)
*/
@@ -0,0 +1,17 @@
<style>
.dups-dialog li.row{ grid-template-columns: 1fr 2fr 5rem;}
.dups-dialog .cell ul{ list-style: none; }
.dups-dialog section.dups section{max-height: 50vh;}
</style>
<div class="dups-dialog">
<section class="dups">
<article eiccard eiccard>
<header>Duplicate emails found across your sources:</header>
<section>
<div data-output="dupsGrid"></div>
</section>
</article>
</section>
</div>
@@ -0,0 +1,47 @@
class MailingDuplicatesDialog extends EICDialogContent {
actions = [
{
label: 'Done',
onclick: this.cancel.bind(this),
severity: 'primary',
role: 'cancel'
}
]
constructor(...args) {
super(...args)
Object.assign(this, app.helpers.activeAttributes)
}
DOMContentLoaded(options) {
let components = ui.eicfy(this.el)
this.setupRefs(components)
this.dupesGrid = new DataGrid(this.outputs.dupsGrid, {
headers: [
{label: 'Email'},
{label: 'Sources'},
{label: 'Row numbers'},
],
actions:[],
})
this.fillDupsGrid(options.dupes)
}
fillDupsGrid(dupes){
let idx=0
for(let email in dupes){
let sources = dupes[email].map( item =>item.sourceName).join('</li><li>')
let rownbs = dupes[email].map( item =>item.rownb+1).join('</li><li>')
let row = this.dupesGrid.addRow(idx, [
email, // expecting source import datestamp
`<ul><li>${sources}</li></ul>`,
`<ul><li>${rownbs}</li></ul>`,
], true)
idx++
}
}
}
app.registerClass('MailingDuplicatesDialog',MailingDuplicatesDialog)
@@ -0,0 +1,15 @@
<style>
.import-dialog{ width: 50vw; }
</style>
<div class="import-dialog">
<section class="import">
<label>Drop or select your Excel file...</label>
<div eicfileupload class="browseBtn"></div>
<article eiccard eiccard collapsable collapsed hidden class="report">
<header><span large class="report-title"></span></header>
<section class="report-details">
</section>
</article>
</section>
</div>
@@ -0,0 +1,251 @@
class MailingExclusionDialog extends EICDialogContent {
actions = [
{
label: 'Cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel'
},
{
label: 'Import & Exclude them',
onclick: this.add.bind(this),
severity: 'primary',
role: 'add',
disabled: true
}
]
constructor(...args) {
super(...args)
Object.assign(this, app.helpers.validators)
}
DOMContentLoaded(options) {
this.model = options.model
this.mid = options.mid
let components = ui.eicfy(this.el)
this.fileUpload = new FileUpload(this.find('.import-dialog .browseBtn'), {
uploadUrl: '#',
uploadMethod: '',
submitLabel:'Analyse file',
allowedExtensions: ['xls','xlsx'],
allowedMimes:['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel'],
allowDrop: true,
maxFiles: 1,
minFiles: 1,
dndLabel: 'Drop your recipents Excel list here !',
noUpload: true,
});
this.fileUpload.onFileAdded = this.onFileAdded.bind(this)
this.fileUpload.onFileRemoved = this.onFileRemoved.bind(this)
this.fileUpload.onWrongFileType = this.onWrongFileType.bind(this)
this.report = this.find('.import-dialog .report')
this.reportTitle = this.find('.import-dialog .report-title')
this.reportDetails = this.find('.import-dialog .report-details')
this.recipientsToImport = {}
}
onFileAdded(fileIdx, file){
let reader = new FileReader()
reader.onload = this.verifyData.bind(this, reader, file)
this.fileUpload.browseBtn.loading = true
reader.readAsArrayBuffer(file)
}
onWrongFileType(file){
ui.show(this.report)
this.fileUpload.browseBtn.loading = false
// because stopping the loading reactivates the button by default (god's will)
if(this.fileUpload.filesList.length==0) this.fileUpload.browseBtn.disabled = false
else this.fileUpload.browseBtn.disabled = true
this.reportTitle.classList.add('error')
this.reportTitle.innerHTML = `Could not import file !`
this.reportDetails.innerHTML = `Wrong file type (${file.type})`
}
verifyData(reader, file){
this.fileUpload.browseBtn.loading = false
// because stopping the loading reactivates the button by default (god's will)
if(this.fileUpload.filesList.length==0) this.fileUpload.browseBtn.disabled = false
else this.fileUpload.browseBtn.disabled = true
let error
let workbook = null
let recipientsArray = null
let recipientsList = null
try{
workbook = XLSX.read(reader.result)
} catch(e) {
console.warn('Trying to parse Excel file:',e)
error = 'Error parsing this as Excel file !'
}
if((!error) && workbook){
let first_sheet = workbook.Sheets[workbook.SheetNames[0]]
try{
recipientsArray = XLSX.utils.sheet_to_json(first_sheet, {
header: 1
})
} catch(e) {
recipientsArray = null
error = 'Error parsing this as Excel sheet !'
}
if(recipientsArray && (!first_sheet['!margins'])) {
recipientsArray = null
error = 'Could not parse this as Excel file !'
}
}
let result = {}
if(recipientsArray){
result = this.getEmailRows(recipientsArray, file)
}
ui.show(this.report)
if(result.recipientsList){
this.reportTitle.classList.remove('error')
this.reportTitle.innerHTML = `Found ${result.recipientsList.length} emails to use as Excusion list!`
let headersList = ''
if(result.availableColumns.some(item => item.hint)){
headersList = result.availableColumns.map(item=>item.hint).join(', ')
}
this.reportDetails.innerHTML = `
<ul>
<li>Column used for the emails: <span success>${result.emailColName}</span></li>
<li><span success>${(result.rejectedRows.length>0) ? result.rejectedRows.length : 'No'}</span> ${ (result.rejectedRows.length==1) ? 'row was' : 'rows where' } rejected because column ${result.emailColName} did not contain an email.</li>
<li>Removed <span success>${result.nbDups}</span> emails that ${result.nbDups.length>1 ? 'where duplicates' : 'was a duplicate'}
</li>
</ul>
`
this.recipientsToImport = result
this.recipientsToImport.sourceName = file.name
this.recipientsToImport.sourceType = 'Excel'
} else {
this.reportTitle.classList.add('error')
this.reportTitle.innerHTML = error
this.reportDetails.innerHTML = ``
this.recipientsToImport = {}
}
if(this.fileUpload.filesList.length==0) this.actions.find(o => o.role == 'add').button.disabled = true
else this.actions.find(o => o.role == 'add').button.disabled = false
}
getEmailRows(recipientsArray, file){
if((!Array.isArray(recipientsArray)) || (recipientsArray.length<1)){
return({
err: 'The parsed Excel is not an array or has no rows !',
recipientsList:[],
importedData:[],
rejectedRows: [],
nbDups: 0,
emailColIdx:null,
emailColName:null,
availableColumns: null,
})
}
//const mailRFC5322Regex = /^([^\x00-\x20\x22\x28\x29\x2c\x2e\x3a-\x3c\x3e\x40\x5b-\x5d\x7f-\xff]+|\x22([^\x0d\x22\x5c\x80-\xff]|\x5c[\x00-\x7f])*\x22)(\x2e([^\x00-\x20\x22\x28\x29\x2c\x2e\x3a-\x3c\x3e\x40\x5b-\x5d\x7f-\xff]+|\x22([^\x0d\x22\x5c\x80-\xff]|\x5c[\x00-\x7f])*\x22))*\x40([^\x00-\x20\x22\x28\x29\x2c\x2e\x3a-\x3c\x3e\x40\x5b-\x5d\x7f-\xff]+|\x5b([^\x0d\x5b-\x5d\x80-\xff]|\x5c[\x00-\x7f])*\x5d)(\x2e([^\x00-\x20\x22\x28\x29\x2c\x2e\x3a-\x3c\x3e\x40\x5b-\x5d\x7f-\xff]+|\x5b([^\x0d\x5b-\x5d\x80-\xff]|\x5c[\x00-\x7f])*\x5d))*$/
//Spot the column where there are the most email-addresses
let colsCount = []
for(let row of recipientsArray){
let colIdx = 0
for(let cell of row){
if(typeof(cell) === null) cell = ''
if(typeof(cell)=='string'){
if(typeof(colsCount[colIdx])=='undefined') colsCount[colIdx] = 0
if(this.validators.isValidEmail(cell)) colsCount[colIdx]++
}
colIdx++
}
}
// cleanup empty slots => don't use "in", or "of" !
for(let idx=0; idx<colsCount.length; idx++){ if(typeof(colsCount[idx])!='number') colsCount[idx]=0 }
// find winning column
let emailCol = colsCount.reduce((acc,val, idx)=>(val>colsCount[acc]?idx:acc),0)
// Now that we know where to look, remove rows without email in that col
let cleanRecipients = []
let rejectedRows = []
let firstValidRow = null
let rowIdx = 1
for(let row of recipientsArray){
let cell = row[emailCol]
if(typeof(cell)!='string'){
rejectedRows.push(rowIdx)
} else {
if(this.validators.isValidEmail(cell)){
cleanRecipients.push(row)
if(!firstValidRow) firstValidRow = rowIdx
}
else rejectedRows.push(rowIdx)
}
rowIdx++
}
// The row before the first valid email row might be headers
let availableColumns
if(firstValidRow>1){
let headersRow = recipientsArray[firstValidRow-2] // rowIdx & firstValidRow start at 1 => row before the first valid is -2 in recipientsArray
availableColumns = headersRow.map( (item, idx) => ({value: idx, hint: ((item.length > 150) ? item.slice(0, 20) + '...' : item ), isEmail: (idx==emailCol) }) )
} else {
availableColumns = recipientsArray[firstValidRow].map( (item, idx) => ({value: idx, hint: '', isEmail: (idx==emailCol) }) )
}
let nodups=[]
const uniqueRecipients = cleanRecipients.filter(item=>{ if(!nodups.includes(item[emailCol])) { nodups.push(item[emailCol]); return(true) } return(false) })
let finalRecipients = cleanRecipients
let recipientsList=[]
for(let recipient of finalRecipients) {
recipientsList.push({
sourceType: "Excel",
sourceName: file.name,
email:recipient[emailCol],
meta:{}, //Empty mappings
})
}
return({
err: false,
recipientsList: recipientsList,
importedData: finalRecipients,
rejectedRows: rejectedRows,
nbDups: cleanRecipients.length - uniqueRecipients.length,
emailColIdx: emailCol,
emailColName: XLSX.utils.encode_col(emailCol),
availableColumns: availableColumns,
})
}
onFileRemoved(fileIdx, file){
if(this.fileUpload.filesList.length==0){ // normally the case as maxFiles=1
this.reportTitle.innerHTML = ''
this.reportDetails.innerHTML = ''
ui.hide(this.report)
this.recipientsToImport = {}
this.actions.find(o => o.role == 'add').button.disabled = true
} else {
this.actions.find(o => o.role == 'add').button.disabled = false
}
}
async add() {
this.actions.find(o => o.role == 'add').button.loading = true
this.recipientsToImport.importDate = (new Date()).toISOString()
await this.model.saveExclusion(this.mid, {
sourceName: this.recipientsToImport.sourceName,
sourceType: this.recipientsToImport.sourceType,
data: this.recipientsToImport.importedData,
availableColumns: this.recipientsToImport.availableColumns,
})
this.commit(true)
}
onFailed() {
this.abort()
}
}
app.registerClass('MailingExclusionDialog',MailingExclusionDialog);
@@ -0,0 +1,75 @@
<style>
.fetch-dialog{ width: 78vw; }
.fetch-dialog .icon-spinner{ top:0; right:1em; }
.fetch-dialog div.field{ margin: .5em 0 0.5em 0; }
.fetch-dialog article.searchCriteria{ max-height:70vh; min-height: 50vh; }
.fetch-dialog article.sourceName header h1, .fetch-dialog article.searchType header h1,
.fetch-dialog article.searchCriteria header h1 {color: var(--eicui-base-color-primary-100);}
.fetch-dialog div.criteria-output{grid-template-columns: 3fr 2fr;}
.fetch-dialog .searchOutput ul{list-style: none; font-size: var(--eicui-base-font-size-s);}
.fetch-dialog header h1 span.icon-info{margin-left: 1em;}
.fetch-dialog .searchCriteria div.cols-2.field-label{grid-template-columns: auto 2em;justify-content: flex-start;}
.fetch-dialog menu.searchCriteriaGroups li { white-space: collapse; width: 8em; }
.fetch-dialog [data-output="resultLegend"] alert {border-width: 1em;}
.fetch-dialog .searchOutput button.collapser{
min-height: calc(var(--eicui-base-spacing-l) + var(--eicui-base-spacing-2xs));
min-width: calc(var(--eicui-base-spacing-l) + var(--eicui-base-spacing-2xs));
}
.fetch-dialog .search-criteria{ grid-template-columns: 1fr 8em; }
.fetch-dialog .sample-refresh{ grid-template-columns: 1fr 1em; }
.fetch-dialog [data-trigger="onRefreshSample"]{ position: absolute; top: 0; right: 1em; }
.fetch-dialog [data-output="resultLegend"] li { padding-top: var(--eicui-base-font-size-2xs); }
.fetch-dialog [data-output="resultLegend"] li .cols-2{ grid-template-columns:1.5em auto; }
.fetch-dialog [data-output="resultLegend"] .color-dot{border-radius: 1em;width: 1.5em; height: 1.5em; display: inline-block; vertical-align: middle; }
.fetch-dialog [data-output="resultLegend"] i{white-space: nowrap; }
.fetch-dialog article.probe-results ul li div.cols-2{ grid-template-columns: 10em auto; }
.fetch-dialog article.probe-results header{ grid-template-columns: auto 4em; }
.fetch-dialog article.probe-results header button {width: 2em; margin-left: 0.2em;}
.fetch-dialog article.probe-results ul li b { background: var(--eicui-base-color-info-25); padding: .2em; margin: .1em 0; }
</style>
<div class="fetch-dialog">
<section>
<div class="cols-2">
<article eiccard class="sourceName">
<header>
<h1>Name for this source in your mailing:</h1>
</header>
<section>
<input eicinput type="text" name="sourceName" placeholder="People in science projects" data-ref="sourceName" data-change="onSourceName">
</section>
</article>
<article eiccard class="searchType">
<header data-async="searchType">
<h1>Type of search</h1>
</header>
<section>
<select eicselect name="searchType" data-ref="searchType" data-change="onsearchType">
<option></option>
</select>
</section>
</article>
</div>
<div class="cols-2 criteria-output">
<article eiccard class="searchCriteria">
<header data-async="searchCriteria">
<h1>Search criteria</h1>
</header>
<section data-output="searchCriteria">
</section>
</article>
<article eiccard class="searchOutput">
<header data-async="searchOutput">
<div class="cols-2 search-criteria">
<h1>Search results</h1>
<button eicbutton small primary data-ref="searchBtn" data-trigger="onFiltersChange" disabled>Search</button>
</div>
</header>
<section data-output="searchOutput">
</section>
</article>
</div>
</section>
</div>
@@ -0,0 +1,349 @@
class MailingFetchDialog extends EICDialogContent {
actions = [
{
label: 'Cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel'
},
{
label: 'Use these recipients',
onclick: this.add.bind(this),
severity: 'primary',
role: 'add',
disabled: true
}
]
constructor(...args) {
super(...args)
Object.assign(this, app.helpers.activeAttributes)
this.currentFilters = []
this.currentQCId = null
this.currentNbResults = 0
this.baseColors = ['success','primary','danger','warning','accent','secondary']
}
DOMContentLoaded(options) {
this.contactsModel = options.models.contacts
this.mailingsModel = options.models.mailings
this.mid = options.mid
const components = ui.eicfy(this.el)
this.setupTriggers(components)
this.setupRefs(components)
this.components.searchType.disabled = true
this.setAsyncLoading(true, ['searchType'])
this.contactsModel.list().then(this.fillQCSelector.bind(this))
}
fillQCSelector(qcList){
for(let query of qcList){
this.components.searchType.el.append(ui.create(
`<option value="${query.id}">${query.title}</option>`
))
}
this.components.searchType.disabled = false
this.setAsyncLoading(false, ['searchType'])
}
onsearchType(component, event){
this.setAsyncLoading(true, ['searchCriteria'])
this.components.searchBtn.disabled = true
this.contactsModel.get(component.value).then(this.buildForm.bind(this))
}
onSourceName(component, event){ this.changeAddBtnState() }
buildForm(query){
this.fieldNames = {}
this.fieldColors = {}
this.output('searchCriteria','')
const tabsMenu = ui.create(`
<menu eictab vertical class="searchCriteriaGroups" xsmall>
${query.group.map(queryGroup => `
<li>
${queryGroup.title}
</li>
`).join('\n')}
</menu>
`)
const tabsDiv = ui.create(`<div class="cols-2 left"></div>`)
this.outputs.searchCriteria.append(tabsDiv)
for(let queryGroup of query.group){
const groupEl = ui.create(`
<article eiccard="" class="tab-content searchCriteriaGroups">
<header>
<h1>${queryGroup.title}
${queryGroup.description ? `<span info="" class="icon-info" title="${queryGroup.description.replace('"','&quot;').replace(/\n/g, ' ')}"></span>`: ''}
</h1>
</header>
<section>
</section>
</article>
`)
const sectionEl = groupEl.querySelector('section')
let colIdx = 0
for(let field of queryGroup.field){
this.fieldNames[field.id] = field.title
this.fieldColors[field.id] = {
severity: this.baseColors[colIdx % this.baseColors.length],
brightness: 100+(20*Math.floor(colIdx/this.baseColors.length))
}
colIdx++
const el = ui.create(`
<div class="cols-2 field">
<div class="cols-2 field-label">
<label title="${field.description ? field.description.replace('"','&quot;'):''}">${field.title}</label>
${field.description ? `<span info="" class="icon-info" title="${field.description.replace('"','&quot;').replace(/\n/g, ' ')}"></span>` : ''}
</div>
</div>
`)
if(!field.type) field.type='text'
switch(field.type){
case 'list':
const selector = ui.create(`<select eicselect multiple name="${field.id}" lookup></select>`)
for(let value of field.value){
selector.append(ui.create(
`<option value="${value}">${value}</option>`
))
}
el.append(selector)
break
case 'date':
el.append(ui.create(`<input eicinput type="date" name="${field.id}">`))
break
default:
el.append(ui.create(`<select eicselect name="${field.id}" editable data-type="array" placeholder="One or several search values (hit enter after each)">`))
}
sectionEl.append(el)
}
tabsDiv.append(groupEl)
}
tabsDiv.prepend(tabsMenu)
const groupsMenu = new Tab(tabsMenu)
groupsMenu.addTabs(tabsDiv.querySelectorAll('menu.searchCriteriaGroups li'), tabsDiv.querySelectorAll('article.searchCriteriaGroups')); //Array.from(this.findAll('menu.searchCriteriaGroups li')).reverse()
const components = ui.eicfy(this.outputs.searchCriteria)
this.setAsyncLoading(false, ['searchCriteria'])
this.filtersForm = new Form(this.outputs.searchCriteria)
this.setupTriggers(components)
this.components.searchBtn.disabled = false
}
onFiltersChange(component, event){
this.currentQCId = this.components.searchType.value
const formValues = this.filtersForm.value
let fieldValues = Object.keys(formValues).map(k => ({id: k, value: formValues[k]}))
this.currentFilters = fieldValues.filter(item => item.value.length>0)
//this.setAsyncLoading(true, ['searchOutput'])
this.components.searchBtn.loading = true
this.contactsModel.getSample(this.currentQCId, this.currentFilters).then(
this.fillSearchOutput.bind(this),
() => this.components.searchBtn.loading = false
//this.setAsyncLoading(false, ['searchOutput'])
)
}
fillSearchOutput(results){
this.components.searchBtn.loading = false
// this.setAsyncLoading(false, ['searchOutput'])
this.output('searchOutput', `
<alert eicalert small muted info>Total recipients: <b>${results.nbRows}</b></alert>
<article eiccard collapsable>
<header eicalert small muted info><h1>Quick Probe</h1></header>
<section>
Email (or part of it)
<input eicinput="" type="search" name="emailProbe" data-ref="probeSearch"/>
<article eiccard class="probe-results"></article>
</section>
</article>
<article eiccard collapsable>
<header eicalert small muted info><h1>Results statistics</h1></header>
<section>
<ul data-output="countersList"></ul>
</section>
</article>
`)
this.outputs.searchOutput.querySelectorAll('[eiccard]').forEach(el => { new Card(el) })
this.outputs.searchOutput.querySelectorAll('[eicbutton]').forEach(el => { new Button(el) })
const probeSearch = new InputSearch(this.outputs.searchOutput.querySelector('[eicinput][type="search"]'))
probeSearch.onQuery = this.onProbe.bind(this,probeSearch)
this.fillCountersLegend(results.counters)
this.currentNbResults = results.nbRows
this.changeAddBtnState()
}
changeAddBtnState(){
if((this.currentNbResults>0) && (this.components.sourceName.value.length>3)) this.actions.find(o => o.role == 'add').button.disabled = false
else this.actions.find(o => o.role == 'add').button.disabled = true
}
onRefreshSample(component){
this.components.sampleRefreshBtn.loading = true
this.contactsModel.getSample(this.currentQCId, this.currentFilters).then(
(results) => {
const el = this.outputs.searchOutput.querySelector('.sample-list')
el.innerHTML = results.rows.map(row => ('<li>'+row.email+'</li>')).join('')
this.components.sampleRefreshBtn.loading = false
},
() => this.components.sampleRefreshBtn.loading = false
)
}
onProbe(component){
component.loading = true
this.setAsyncLoading(false, ['quickprobe'])
this.outputs.searchOutput.querySelector('article.probe-results').innerHTML = ''
this.contactsModel.probe(this.currentQCId, this.currentFilters, component.value).then((payload) =>{
this.fillProbeResults(payload)
component.loading = false
},
() => { component.loading = false }
)
}
fillProbeResults(results){
this.lastProbeResults = results
this.lastProbeIndex = 0
if(results.probeMatchesNumber==0){
this.outputs.searchOutput.querySelector('article.probe-results').innerHTML = `<header>No email found containing that text.</header>`
return
}
this.showProbeResult()
}
showProbeResult(){
this.outputs.searchOutput.querySelector('article.probe-results').innerHTML = ''
this.outputs.searchOutput.querySelector('article.probe-results').append(ui.create(`
<header>
<div>
<span>Found ${this.lastProbeResults.probeMatchesNumber} matching</span>
<span xsmall data-output="probeIndex"></span>
</div>
<span class="next-prev"></span>
</header>
`))
this.outputs.searchOutput.querySelector('article.probe-results').append(ui.create(`<section></section>`))
this.probeMaxIndex = this.lastProbeResults.probeMatchesNumber<20 ? this.lastProbeResults.probeMatchesNumber-1 : 19
this.components.prevProbeShow = new Button(null, {
label:'&lt;',
size: 'xxsmall',
onclick: () => {
this.lastProbeIndex = (this.lastProbeIndex>0) ? this.lastProbeIndex-1 : this.probeMaxIndex
this.showOneProbeMatch()
}
});
this.outputs.searchOutput.querySelector('.next-prev').append(this.components.prevProbeShow.el)
this.components.nextProbeShow = new Button(null, {
label:'&gt;',
size: 'xxsmall',
onclick: () => {
this.lastProbeIndex = (this.lastProbeIndex<(this.probeMaxIndex)) ? this.lastProbeIndex+1 : 0
this.showOneProbeMatch()
}
});
this.outputs.searchOutput.querySelector('.next-prev').append(this.components.nextProbeShow.el)
this.showOneProbeMatch()
}
showOneProbeMatch(){
const row = this.lastProbeResults.probeMatches[this.lastProbeIndex]
let html='<ul>'
for(const key of Object.keys(row)){
html += `<li><div class='cols-2'>
<b>${row[key].title}</b>
<i>${Array.isArray(row[key].value) ? row[key].value.join(', ') : row[key].value}</i>
</div></li>`
}
html += '</ul>'
this.outputs.searchOutput.querySelector('article.probe-results section').innerHTML = ''
this.outputs.searchOutput.querySelector('article.probe-results section').append(ui.create(html))
this.outputs.searchOutput.querySelector('[data-output="probeIndex"]').innerHTML = ` (Showing ${this.lastProbeIndex+1}/${this.probeMaxIndex+1})`
}
fillCountersLegend(counters){
const el = this.outputs.searchOutput.querySelector('[data-output="countersList"]')
el.innerHTML = ''
for(let counterName in counters) {
const liel = ui.create(`
<li>
<article eiccard collapsable collapsed>
<header><div>${counterName} <span eicchip xxsmall>${counters[counterName].nbUnique} unique</span></div></header>
<section>
<div class="cols-2 resultCounter">
<ul data-output="resultLegend"></ul>
<div data-output="resultPie"></div>
</div>
</section>
</alert>
</li>
`)
el.append(liel)
const ulel = liel.querySelector('[data-output="resultLegend"]')
let colIdx = 0
const pieData = []
for(let value in counters[counterName].counters) {
const severity = this.baseColors[colIdx % this.baseColors.length]
const brightness = 100-(20*Math.floor(colIdx/this.baseColors.length))
ulel.append(ui.create(`
<li><div class="cols-2">
<div class="color-dot" style="background: var(--eicui-base-color-${(severity!='secondary') ? severity : 'grey'}-100);filter:brightness(${brightness}%");></div>
<div>
<b>${value}:</b>
<i>${ (100*counters[counterName].counters[value]/counters[counterName].total).toFixed(1) }% (${counters[counterName].counters[value]})</i>
</div>
</div>
</li>
`))
pieData.push({
value: counters[counterName].counters[value],
severity: severity,
brightness: `${brightness}%`,
})
colIdx++
}
new PieTreeChart(liel.querySelector('[data-output="resultPie"]'), {
title: '',
height: 200,
data: pieData,
angularGap: 1,
innerRadius: 0,
outerRadius: 50
})
}
el.querySelectorAll('[eiccard]').forEach(el => { new Card(el) })
}
async add() {
this.actions.find(o => o.role == 'add').button.loading = true
this.currentQCId = this.components.searchType.value
const formValues = this.filtersForm.value
let fieldValues = Object.keys(formValues).map(k => ({id: k, value: formValues[k]}))
this.currentFilters = fieldValues.filter(item => item.value.length>0)
await this.mailingsModel.saveImport(this.mid, {
sourceName: this.components.sourceName.value,
sourceType: 'Marklogic',
data: null,
availableColumns: null,
query_id: this.currentQCId,
query_params: this.currentFilters,
})
this.commit(true)
}
onFailed() {
this.abort()
}
}
app.registerClass('MailingFetchDialog',MailingFetchDialog);
@@ -0,0 +1,23 @@
<style>
.import-dialog{ width: 50vw; }
</style>
<div class="import-dialog">
<section class="import">
<label>Drop or select your Excel file...</label>
<div eicfileupload class="browseBtn"></div>
<article eiccard eiccard>
<section>
<div class="cols-2 switches">
Remove duplicates
<input eicinput type="toggler" name="removeDups" primary="" labelleft="no" labelright="yes" xsmall="" value="yes" aria-enabled="true" classOff="greyed" class="remove-dups"/>
</div>
</section>
</article>
<article eiccard eiccard collapsable collapsed hidden class="report">
<header><span large class="report-title"></span></header>
<section class="report-details">
</section>
</article>
</section>
</div>
@@ -0,0 +1,260 @@
class MailingImportDialog extends EICDialogContent {
actions = [
{
label: 'Cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel'
},
{
label: 'Import recipients',
onclick: this.add.bind(this),
severity: 'primary',
role: 'add',
disabled: true
}
]
constructor(...args) {
super(...args)
Object.assign(this, app.helpers.validators)
}
DOMContentLoaded(options) {
this.model = options.model
this.mid = options.mid
let components = ui.eicfy(this.el)
this.fileUpload = new FileUpload(this.find('.import-dialog .browseBtn'), {
uploadUrl: '#',
uploadMethod: '',
submitLabel:'Analyse file',
allowedExtensions: ['xls','xlsx'],
allowedMimes:['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel'],
allowDrop: true,
maxFiles: 1,
minFiles: 1,
dndLabel: 'Drop your recipents Excel list here !',
noUpload: true,
});
this.fileUpload.onFileAdded = this.onFileAdded.bind(this)
this.fileUpload.onFileRemoved = this.onFileRemoved.bind(this)
this.fileUpload.onWrongFileType = this.onWrongFileType.bind(this)
this.report = this.find('.import-dialog .report')
this.reportTitle = this.find('.import-dialog .report-title')
this.reportDetails = this.find('.import-dialog .report-details')
this.removeDups = components.find(item => (item.el.classList.contains('remove-dups')))
this.recipientsToImport = {}
}
onFileAdded(fileIdx, file){
let reader = new FileReader()
reader.onload = this.verifyData.bind(this, reader, file)
this.fileUpload.browseBtn.loading = true
reader.readAsArrayBuffer(file)
}
onWrongFileType(file){
ui.show(this.report)
this.fileUpload.browseBtn.loading = false
// because stopping the loading reactivates the button by default (god's will)
if(this.fileUpload.filesList.length==0) this.fileUpload.browseBtn.disabled = false
else this.fileUpload.browseBtn.disabled = true
this.reportTitle.classList.add('error')
this.reportTitle.innerHTML = `Could not import file !`
this.reportDetails.innerHTML = `Wrong file type (${file.type})`
}
verifyData(reader, file){
this.fileUpload.browseBtn.loading = false
// because stopping the loading reactivates the button by default (god's will)
if(this.fileUpload.filesList.length==0) this.fileUpload.browseBtn.disabled = false
else this.fileUpload.browseBtn.disabled = true
let error
let workbook = null
let recipientsArray = null
let recipientsList = null
try{
workbook = XLSX.read(reader.result)
} catch(e) {
console.warn('Trying to parse Excel file:',e)
error = 'Error parsing this as Excel file !'
}
if((!error) && workbook){
let first_sheet = workbook.Sheets[workbook.SheetNames[0]]
try{
recipientsArray = XLSX.utils.sheet_to_json(first_sheet, {
header: 1
})
} catch(e) {
recipientsArray = null
error = 'Error parsing this as Excel sheet !'
}
if(recipientsArray && (!first_sheet['!margins'])) {
recipientsArray = null
error = 'Could not parse this as Excel file !'
}
}
let result = {}
if(recipientsArray){
result = this.getEmailRows(recipientsArray, file)
}
ui.show(this.report)
if(result.recipientsList){
this.reportTitle.classList.remove('error')
this.reportTitle.innerHTML = `Ready to import ${result.recipientsList.length} recipients !`
let headersList = ''
if(result.availableColumns.some(item => item.hint)){
headersList = result.availableColumns.map(item=>item.hint).join(', ')
}
this.reportDetails.innerHTML = `
<ul>
<li>Column used for the emails: <span success>${result.emailColName}</span></li>
<li><span success>${(result.rejectedRows.length>0) ? result.rejectedRows.length : 'No'}</span> ${ (result.rejectedRows.length==1) ? 'row was' : 'rows where' } rejected because column ${result.emailColName} did not contain an email.</li>
<li> ${(this.removeDups.value=='yes') ?
`Removed <span success>${result.nbDups}</span> emails that ${result.nbDups.length>1 ? 'where duplicates' : 'was a duplicate'}`:
`<span danger >I detected ${result.nbDups} duplicate${result.nbDups.length>1 ? 's' : ''} !</span> (which I did not remove)`}
</li>
<li><span ${headersList ? 'success': 'warning'}>${headersList ? 'Potential headers row: ': 'None headers found.'}</span>${headersList}</li>
</ul>
`
this.recipientsToImport = result
this.recipientsToImport.sourceName = file.name
this.recipientsToImport.sourceType = 'Excel'
} else {
this.reportTitle.classList.add('error')
this.reportTitle.innerHTML = error
this.reportDetails.innerHTML = ``
this.recipientsToImport = {}
}
if(this.fileUpload.filesList.length==0) this.actions.find(o => o.role == 'add').button.disabled = true
else this.actions.find(o => o.role == 'add').button.disabled = false
}
getEmailRows(recipientsArray, file){
if((!Array.isArray(recipientsArray)) || (recipientsArray.length<1)){
return({
err: 'The parsed Excel is not an array or has no rows !',
recipientsList:[],
importedData:[],
rejectedRows: [],
nbDups: 0,
emailColIdx:null,
emailColName:null,
availableColumns: null,
})
}
//const mailRFC5322Regex = /^([^\x00-\x20\x22\x28\x29\x2c\x2e\x3a-\x3c\x3e\x40\x5b-\x5d\x7f-\xff]+|\x22([^\x0d\x22\x5c\x80-\xff]|\x5c[\x00-\x7f])*\x22)(\x2e([^\x00-\x20\x22\x28\x29\x2c\x2e\x3a-\x3c\x3e\x40\x5b-\x5d\x7f-\xff]+|\x22([^\x0d\x22\x5c\x80-\xff]|\x5c[\x00-\x7f])*\x22))*\x40([^\x00-\x20\x22\x28\x29\x2c\x2e\x3a-\x3c\x3e\x40\x5b-\x5d\x7f-\xff]+|\x5b([^\x0d\x5b-\x5d\x80-\xff]|\x5c[\x00-\x7f])*\x5d)(\x2e([^\x00-\x20\x22\x28\x29\x2c\x2e\x3a-\x3c\x3e\x40\x5b-\x5d\x7f-\xff]+|\x5b([^\x0d\x5b-\x5d\x80-\xff]|\x5c[\x00-\x7f])*\x5d))*$/
//Spot the column where there are the most email-addresses
let colsCount = []
for(let row of recipientsArray){
let colIdx = 0
for(let cell of row){
if(typeof(cell) === null) cell = ''
if(typeof(cell)=='string'){
if(typeof(colsCount[colIdx])=='undefined') colsCount[colIdx] = 0
if(this.validators.isValidEmail(cell)) colsCount[colIdx]++
}
colIdx++
}
}
// cleanup empty slots => don't use "in", or "of" !
for(let idx=0; idx<colsCount.length; idx++){ if(typeof(colsCount[idx])!='number') colsCount[idx]=0 }
// find winning column
let emailCol = colsCount.reduce((acc,val, idx)=>(val>colsCount[acc]?idx:acc),0)
// Now that we know where to look, remove rows without email in that col
let cleanRecipients = []
let rejectedRows = []
let firstValidRow = null
let rowIdx = 1
for(let row of recipientsArray){
let cell = row[emailCol]
if(typeof(cell)!='string'){
rejectedRows.push(rowIdx)
} else {
if(this.validators.isValidEmail(cell)){
cleanRecipients.push(row)
if(!firstValidRow) firstValidRow = rowIdx
}
else rejectedRows.push(rowIdx)
}
rowIdx++
}
// The row before the first valid email row might be headers
let availableColumns
if(firstValidRow>1){
let headersRow = recipientsArray[firstValidRow-2] // rowIdx & firstValidRow start at 1 => row before the first valid is -2 in recipientsArray
availableColumns = headersRow.map( (item, idx) => ({value: idx, hint: ((item.length > 150) ? item.slice(0, 20) + '...' : item ), isEmail: (idx==emailCol) }) )
} else {
availableColumns = recipientsArray[firstValidRow].map( (item, idx) => ({value: idx, hint: '', isEmail: (idx==emailCol) }) )
}
let nodups=[]
const uniqueRecipients = cleanRecipients.filter(item=>{ if(!nodups.includes(item[emailCol])) { nodups.push(item[emailCol]); return(true) } return(false) })
let finalRecipients
if(this.removeDups.value=='yes'){
finalRecipients = uniqueRecipients
} else {
finalRecipients = cleanRecipients
}
let recipientsList=[]
for(let recipient of finalRecipients) {
recipientsList.push({
sourceType: "Excel",
sourceName: file.name,
email:recipient[emailCol],
meta:{}, //Empty mappings
})
}
return({
err: false,
recipientsList: recipientsList,
importedData: finalRecipients,
rejectedRows: rejectedRows,
nbDups: cleanRecipients.length - uniqueRecipients.length,
emailColIdx: emailCol,
emailColName: XLSX.utils.encode_col(emailCol),
availableColumns: availableColumns,
})
}
onFileRemoved(fileIdx, file){
if(this.fileUpload.filesList.length==0){ // normally the case as maxFiles=1
this.reportTitle.innerHTML = ''
this.reportDetails.innerHTML = ''
ui.hide(this.report)
this.recipientsToImport = {}
this.actions.find(o => o.role == 'add').button.disabled = true
} else {
this.actions.find(o => o.role == 'add').button.disabled = false
}
}
async add() {
this.actions.find(o => o.role == 'add').button.loading = true
this.recipientsToImport.importDate = (new Date()).toISOString()
await this.model.saveImport(this.mid, {
sourceName: this.recipientsToImport.sourceName,
sourceType: this.recipientsToImport.sourceType,
data: this.recipientsToImport.importedData,
availableColumns: this.recipientsToImport.availableColumns,
})
this.commit(true)
}
onFailed() {
this.abort()
}
}
app.registerClass('MailingImportDialog',MailingImportDialog);
@@ -0,0 +1,10 @@
<style>
.mapping-dialog{ width: 50vw; }
</style>
<div class="mapping-dialog">
<label>Select a column for<span eicchip info xsmall>${token}</span></label>
<select eicselect name="column" small></select>
<div eicalert warning small data-output="warning"></div>
<div eicdatagrid class="sample-grid" footer="hidden"></div>
</div>
@@ -0,0 +1,129 @@
class MailingMappingDialog extends EICDialogContent {
actions = [
{
label: 'Cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel'
},
{
label: 'Link',
onclick: this.link.bind(this),
severity: 'primary',
role: 'link',
disabled: false
}
]
DOMContentLoaded(options) {
this.model = options.model
this.token = options.token
this.sourceId = options.sourceId
this.currentMailing = options.currentMailing
this.oneMapping = this.currentMailing.mappings.find(item => item.sourceId==options.sourceId) || { mappings: {} }
this.oneImport = this.currentMailing.imports.find(item => item.sourceId == options.sourceId)
let value = this.oneMapping.mappings[this.token] ? this.oneMapping.mappings[this.token].value : null
let components = ui.eicfy(this.el)
this.warning = this.find('[data-output="warning"]')
ui.hide(this.warning)
this.selector = components.find(component => component.el.name == 'column')
for(let column of this.oneImport.availableColumns ) {
let opt = `<option value="${column.value}">Column ${XLSX.utils.encode_col(column.value)}`
if(column.hint) {
opt += ` (${(column.hint.length<51) ? column.hint : column.hint.slice(0,30)+'..…'})`
}
opt +='</option>'
this.selector.el.append(ui.create(opt))
}
this.sampleGrid = new DataGrid(this.find('.sample-grid'), {
headers: [ {label: 'Sample data'} ],
height: '20vh'
})
this.selector.value = value
this.selector.change(this.onColChange.bind(this));
this.onColChange()
this.btResample = new Button(null, {icon: 'icon-refresh', rounded: true, size: 'xsmall', hint: 'Resample'})
this.btResample.click = this.onColChange.bind(this)
this.sampleGrid.el.querySelector('header .cell.actions').append(this.btResample.el)
}
onColChange(event){
const size = Math.min(10, this.oneImport.dataCount)
const colIdx = this.selector.value
let warnings = []
this.sampleGrid.clear()
if(this.selector.value){
for(let[idx, row] of this._makeSample(this.oneImport.data).entries()) {
let value = String(row[colIdx] || '').trim() || `<span eicchip xsmall danger>Empty !</span>`
this.sampleGrid.addRow( idx, [ value ] )
}
if(this.oneImport.data.some(row => (row[colIdx].trim()==''))){
warnings.push('<div>Some value in this column are empty !</div>')
}
if(this.oneImport.availableColumns.find(c => c.value == colIdx).isEmail) {
warnings.push('<div>Column used as recipients email address !</div>')
}
let tokens = []
for(let token in this.oneMapping.mappings) {
if(this.oneMapping.mappings[token].value == colIdx && token != this.token) tokens.push(token)
}
if(tokens.length > 0) {
warnings.push(`<div>Column already mapped to ${tokens.join(', ')} !</div>`)
}
}
this.warning.innerHTML = ''
ui.hide(this.warning)
if(warnings.length > 0) {
ui.show(this.warning)
this.warning.innerHTML = warnings.join('')
}
}
_makeSample(arr){
let sample = []
let noDups = []
for(let i=0; i<Math.min(10, arr.length); i++){
let rowNb=Math.floor(Math.random() * arr.length)
if(noDups.includes(rowNb)){
i--
} else {
sample.push(arr[rowNb])
noDups.push(rowNb)
}
}
return(sample)
}
async link() {
const colIdx = parseInt(this.selector.value)
if(!Number.isNaN(colIdx)) {
this.actions.find(o => o.role == 'link').button.loading = true
let curmap = this.currentMailing.mappings.find(item => item.sourceId==this.sourceId )
curmap.mappings[this.token] = { value: colIdx, hint: this.oneImport.availableColumns[colIdx].hint }
await this.model.save(this.currentMailing)
this.commit(true)
} else {
this.commit(false)
}
}
}
app.registerClass('MailingMappingDialog',MailingMappingDialog);
@@ -0,0 +1,74 @@
<style>
.template-selection .cols-2.left{ grid-template-columns: 400px auto; }
.template-selection .file-browser{ height: 500px; }
.template-selection .preview { height: 75vh; }
.template-selection .file-browser .dataset {
min-width: 30vw;
max-width: 40vw;
height: 60vh;
}
.template-selection .file-browser [eicdatagrid] .row { grid-template-columns: 60px auto 80px; }
.template-selection .preview .empty:after {
background-color: var();
position: absolute;
top: 0;
left: 0;
}
.template-selection .preview .mail-subject {
padding: var(--eicui-base-spacing-s);
border: 1px solid var(--eicui-base-color-grey-10);
margin: var(--eicui-base-spacing-xs) 0 var(--eicui-base-spacing-m) 0;
}
.template-selection .preview .mail-content {
min-width: 40vw;
width: 1fr;
height: 50vh;
overflow: auto;
border: 1px solid var(--eicui-base-color-grey-10);
}
.template-selection .confirmation {
display: none;
}
.template-selection .file-browser [eicdatagrid] .dataset .row .cell:nth-child(2),
.template-selection .file-browser [eicdatagrid] .dataset .row .cell:nth-child(4) {
text-align: center;
}
.template-selection [eicdatagrid] .row.selected{
background-color: var(--eicui-base-color-info);
color: white;
}
.template-selection [eicdatagrid] .row.selected .ffs.icon-new { color:white }
.template-selection [data-output="templateWarning"]{ display:none; }
</style>
<div class="template-selection">
<div class="cols-2 left">
<article eiccard class="file-browser">
<header>
<h1>Templates</h1>
<h2 data-output="path"></h2>
</header>
<section>
<div eicdatagrid header="hidden" footer="hidden"></div>
</section>
</article>
<article eiccard class="preview">
<header>
<h1 data-output="template"></h1>
<div eicalert warning data-output="templateWarning"></div>
</header>
<section class="empty">
<label>Subject</label>
<div class="mail-subject"></div>
<label>Message</label>
<div class="mail-content html"></div>
</section>
</article>
</div>
<div class="confirmation">
<div class="cols-2 left">
<input eicinput type="checkbox" value="yes">
<span>Yes, replace previous content with this template</span>
</div>
</div>
</div>
@@ -0,0 +1,187 @@
/**
* Todos
* - complete change() mmethod when template selected
*/
class MailingTemplatePreviewDialog extends EICDialogContent {
actions = [
{
label: 'Cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel'
},
{
label: 'Use',
onclick: this.change.bind(this),
severity: 'primary',
role: 'change',
disabled: true
}
]
constructor(...args) {
super(...args)
Object.assign(this, app.helpers.validators)
Object.assign(this, app.helpers.activeAttributes)
}
DOMContentLoaded(options) {
this.templateModel = options.templateModel
this.mailingModel = options.mailingModel
this.currentMailing = options.currentMailing
this.currentTemplateInfo = {...this.currentMailing.template}
this.templateStatus = options.status
const components = ui.eicfy(this.el)
this.setupTriggers(components)
this.setupRefs(components)
this.fileBrowser = new DataGrid(this.find('.file-browser [eicdatagrid]'), {
headers: [
{label: '', filter: null, sortable:false},
{label: 'name', filter: 'text', sortable:true},
],
})
this.fileBrowser.onRowClick = this.onPathChange.bind(this)
this.fileBrowser.loading = true
this.preview = new Card(this.find('.preview'))
this.shadowRoot = this.find('.mail-content.html').attachShadow({ mode: 'open' })
this.refreshFolder(true)
}
showPath(path){
this.currentPath = path
const dirs = path.split('/').filter(item=>item)
const pathChips = dirs.map((dir, idx) => {
const path = dirs.slice(0, idx+1).join('/')
return(`<span eicchip xxsmall info data-path="/${path}">${dir}</span>`)
}).join('')
this.output('path', pathChips)
}
fillFolder(templates) {
this.fileBrowser.clear()
this.fileBrowser.loading = false
this.showPath(this.templateModel.ffs.currentPath)
for(let folder of this.templateModel.ffs.getFolders()){
let row = this.fileBrowser.addRow('./'+folder.name,
[
`<span warning xsmall class="ffs icon-folder"></span>`,
folder.name,
])
row.dataset.type = 'folder'
}
for(let template of templates){
if(template.path == this.templateModel.ffs.currentPath){ // Important on 1st display whne we havent't CD yet
let row = this.fileBrowser.addRow(template.id, [
`<span info xsmall class="ffs icon-new"></span>`,
template.name.length>35 ? template.name.slice(0,35)+'...' : template.name
])
row.dataset.type = 'file'
}
}
this.fileBrowser.loading = false
if(this.currentTemplateInfo && this.currentTemplateInfo.id) this.onFileSelect(this.fileBrowser.getRowById(this.currentTemplateInfo.id))
}
async refreshFolder(pathToTemplate){
this.fileBrowser.clear()
this.fileBrowser.loading = true
let templates
if(pathToTemplate){ // called from DOMContentLoaded => position to current templat, otherwise, let user move around)
templates = await this.templateModel.search([this.currentTemplateInfo.path || '/'], this.templateStatus )
this.templateModel.ffs.changeDir(this.currentTemplateInfo.path || '/')
} else {
templates = await this.templateModel.search([this.templateModel.ffs.currentPath], this.templateStatus )
}
this.fillFolder(templates)
}
onPathChange(row){
if(row.dataset.type=='file') {
this.onFileSelect(row)
} else if(row.dataset.type=='folder') {
this.onFolderSelect(row.dataset.id)
}
}
async onFileSelect(row){
if(!row) return
this.preview.loading = true
this.fileBrowser.rows().forEach(el => el.classList.remove('selected'))
this.currentTemplateInfo = await this.templateModel.get(row.dataset.id)
if(this.currentTemplateInfo.id){
this.currentTokens = this.templateModel.getTokens(this.currentTemplateInfo)
row.classList.add('selected')
this.refreshTemplate(this.currentTemplateInfo)
}
this.preview.loading = false
}
onFolderSelect(path){
this.templateModel.ffs.changeDir(path)
this.refreshFolder(false)
}
refreshTemplate(templateInfo){
const filterText = (text) => {
let element = document.createElement('div')
element.textContent = text
text = element.innerHTML
text = text.length > 150 ? text.slice(0, 100) + '...' : text
return text
}
this.output('template', templateInfo.name.length>35 ? templateInfo.name.slice(0,35)+'...' : templateInfo.name)
this.outputs['template'].setAttribute('title', templateInfo.name)
let subject
if((!templateInfo.meta.subject) || (!templateInfo.meta.subject.trim())){
this.output('templateWarning', 'This template has no subject and cannot be used here !')
ui.show(this.outputs['templateWarning'])
subject = ''
this.actions.find(o => o.role == 'change').button.disabled = true
} else {
ui.hide(this.outputs['templateWarning'])
subject = filterText(templateInfo.meta.subject || '')
this.find('.mail-subject').innerHTML = `${subject.replace(this.validators.mustacheTokenRegex,'<span class="token" eicchip success>$1</span>')}`
this.actions.find(o => o.role == 'change').button.disabled = false
}
let html = atob(templateInfo.html).replace(this.validators.mustacheTokenRegex,'<span class="token" eicchip success>$1</span>')
this.shadowRoot.innerHTML = `
<style>
span.token[eicchip]{
border-radius: 60px !important;
background-color: var(--eicui-base-color-success-100);
color: var(--eicui-base-color-white);
font-size: 0.75rem;
padding: var(--eicui-base-spacing-2xs) var(--eicui-base-spacing-s);
min-height: var(--eicui-base-spacing-xl);
display: inline-grid;
grid-template-columns: ;
min-content min-content min-content;
min-height: 0;
align-items: center;
margin: 1px var(--eicui-base-spacing-2xs);
pointer-events: all;
white-space: nowrap;
position: relative;
text-shadow: none;
}
</style>
${html}`
}
async change() {
this.currentMailing.template = { id: this.currentTemplateInfo.id }
this.actions.find(o => o.role == 'change').button.loading = true
await this.mailingModel.save(this.currentMailing)
this.commit(true)
}
}
app.registerClass('MailingTemplatePreviewDialog',MailingTemplatePreviewDialog);
@@ -0,0 +1,16 @@
<div class="test-dialog">
<section class="test">
<article eiccard eiccard>
<header>Give a valid email address for the recipient of this test :</header>
<section class="test-email">
<input eicinput type="text" name="email" eicinput value="" class="mail-address">
</section>
</article>
<article eiccard eiccard class="tokens-form">
<header>Give values for variables :</header>
<section class="test-tokens">
</section>
</article>
</section>
</div>
@@ -0,0 +1,89 @@
class MailingTestDialog extends EICDialogContent {
actions = [
{
label: 'Cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel',
},
{
label: 'Send test',
onclick: this.send.bind(this),
severity: 'primary',
role: 'send',
disabled: true
}
]
constructor(...args) {
super(...args)
Object.assign(this, app.helpers.validators)
}
DOMContentLoaded(options) {
this.model = options.model
let components = ui.eicfy(this.el)
this.form = new Form(this.find('.test-dialog'));
this.mailInput = components.find(item => item.el.classList.contains('mail-address'))
this.mailInput.el.addEventListener('keyup', this.checkEmail.bind(this))
this.mailInput.el.addEventListener('focus', this.checkEmail.bind(this))
this.mailInput.el.addEventListener('blur', this.checkEmail.bind(this))
this.mailInput.value = app.User.identity.email
this.tokensForm = components.find(item => item.el.classList.contains('tokens-form'))
if(options.tokens.length>0){
ui.show(this.tokensForm.el, 'flex')
this.fillTokenForm(options.tokens)
} else {
ui.hide(this.tokensForm.el)
}
if(this.validators.isValidEmail(this.mailInput.value)) this.actions[1].disabled = false
}
fillTokenForm(tokens){
let markup = `<ul>
<li>
<div class="cols-2">
<label>Token</label>
<label>Value</label>
</div>
</li>`
tokens.forEach(token => {
markup += `<li>
<div class="cols-2">
<div><span eicchip small success>${token}</span></div>
<input eicinput type="text" name="${token}" data-path="tokenValues" eicinput value="" class="token-value">
</div>
</li>
`
})
markup += `</ul>`
this.tokensForm.el.innerHTML = markup
}
checkEmail(event){
if(event) {
event.preventDefault()
event.stopPropagation()
}
if(this.validators.isValidEmail(this.mailInput.value)){
this.actions.find(o => o.role == 'send').button.disabled = false
} else {
this.actions.find(o => o.role == 'send').button.disabled = true
}
}
send() {
if(!this.validators.isValidEmail(this.mailInput.value)) return
let data = this.form.value
console.log(data)
this.commit(data)
}
onFailed() {
this.abort()
}
}
app.registerClass('MailingTestDialog',MailingTestDialog);
@@ -0,0 +1,13 @@
<article eiccard eiccard collapsable collapsed hidden class="pub-decision">
<section>
<div class="forms decision-dialog">
<div class="eicui-input-container decision">
<label>You're about to request a review for this template.</label>
<label>Remarks or comments for the reviewer:</label>
<textarea eictextarea hint="Please don't be gross." name="decision" eicinput value="" class="tpl-decision"></textarea>
</div>
</div>
</section>
</article>
@@ -0,0 +1,51 @@
class TemplatesDecisionDialog extends EICDialogContent {
actions = [
{
label: 'Cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel'
},
{
label: 'Validate',
onclick: this.validate.bind(this),
severity: 'primary',
role: 'validate',
disabled: true
}
]
constructor() {
super()
Object.assign(this, app.helpers.validators)
}
DOMContentLoaded(options) {
this.form = new Form(this.find('.decision-dialog'));
this.decisionTextarea = this.find('.tpl-decision');
this.decisionTextarea.addEventListener('keyup', this.checkDecisionTextarea.bind(this))
}
checkDecisionTextarea(event) {
event.preventDefault()
event.stopPropagation()
if (this.decisionTextarea.value.trim().length > 0) {
this.actions.find(o => o.role == 'validate').button.disabled = false
}
}
validate() {
let data = { comment: this.decisionTextarea.value }
this.commit(data)
}
onFailed() {
this.abort()
}
}
app.registerClass('TemplatesDecisionDialog', TemplatesDecisionDialog);
@@ -0,0 +1,35 @@
<style>
.tpl.testMail {
color: var(--eicui-base-color-info-110);
font-family: var(--eicui-base-font-family);
font-size: var(--eicui-base-font-size-l);
font-weight: 500;
letter-spacing: -.25px;
border-bottom: 1px solid var(--eicui-base-color-grey-25);
}
.tpl .test-email {
display: flex;
flex-direction: column;
gap: 5px;
}
.tpl .mail-address {
width: 30em;
}
</style>
<article eiccard eiccard collapsable collapsed hidden class="tpl testMail">
<header>
<h3>Sending a test email</h3>
</header>
<section>
<div class="tpl test-mail-dialog">
<div class="eicui-input-container test-email">
<div>Enter a valid email address</div>
<div>
<input eicinput type="text" name="email" eicinput value="" class="tpl mail-address">
</div>
</div>
</div>
</section>
</article>
@@ -0,0 +1,63 @@
class TemplatesMailTestDialog extends EICDialogContent {
actions = [
{
label: 'Cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel'
},
{
label: 'Send test',
onclick: this.add.bind(this),
severity: 'primary',
role: 'add',
disabled: true
}
]
constructor() {
super()
Object.assign(this, app.helpers.validators)
}
DOMContentLoaded() {
this.form = new Form(this.find('.test-mail-dialog'))
this.mailInput = this.find('.mail-address')
this.mailInput.addEventListener('keyup', this.checkEmail.bind(this))
// Use user email by default
this.mailInput.value = app.User.identity.email || ''
// force validation on init
setTimeout(() => this.checkEmail(), 0)
}
checkEmail(event = null){
if(event){
event.preventDefault()
event.stopPropagation()
}
const addAction = this.actions.find(o => o.role === 'add')
if (!addAction || !addAction.button) {
return
}
const isValid = this.validators.isValidEmail(this.mailInput.value)
addAction.button.disabled = !isValid
}
add() {
if(!this.validators.isValidEmail(this.mailInput.value)) return
let data = { email: this.mailInput.value }
this.commit(data)
}
onFailed() {
this.abort()
}
}
app.registerClass('TemplatesMailTestDialog', TemplatesMailTestDialog)
@@ -0,0 +1,49 @@
<style>
.uplImg h6 {
margin-top: auto;
}
.uplImg .uplImg-AllowedExt {
text-align: center;
}
.dialog-spinner {
display: flex;
align-items: center;
justify-content: center;
padding: 1em;
font-size: 1.1em;
color: #333;
}
.eic-spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
width: 24px;
height: 24px;
animation: spin 1s linear infinite;
margin-right: 10px;
}
@keyframes spin {
0% { transform: rotate(0deg);}
100% { transform: rotate(360deg);}
}
</style>
<article eiccard eiccard collapsable collapsed hidden class="uplImg">
<header>
<h6>Upload images </h6>
<span>Allowed extensions:</span>
<div class="uplImg-AllowedExt">
<span eicbadge xlarge success>jpg</span>
<span eicbadge xlarge success>jpeg</span>
<span eicbadge xlarge success>png</span>
<span eicbadge xlarge success>svg</span>
</div>
</header>
<section>
<div class="forms upload-img-dialog">
<div class="eicui-input-container upload-img">
<div eicfileupload class="browseBtn"></div>
</div>
</div>
</section>
</article>
@@ -0,0 +1,112 @@
class TemplatesUplImgDialog extends EICDialogContent {
constructor() {
super();
Object.assign(this, app.helpers.validators); // Inject validators
this.actions = [
{
label: 'Cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel'
}
];
}
DOMContentLoaded() {
const imgId = crypto.randomUUID()
const host = window.location.hostname
let envUrl = (host.split('.')[1] !== 'eismea') ? host.split('.')[1] + '.' : ''
// Initialize file upload
this.fileUpload = new FileUpload(this.find('.browseBtn'), {
uploadId: imgId,
uploadUrl: `https://myeic.${envUrl}eismea.eu/public/images/common/${imgId}`,
uploadMethod: 'PUT',
allowedExtensions: ['jpg', 'jpeg', 'png', 'svg'],
allowedMimes: ['image/jpeg', 'image/pjpeg', 'image/png', 'image/svg+xml'],
allowDrop: true,
maxFiles: 1,
minFiles: 1,
dndLabel: 'Drop your image here !',
imageTitle: '',
noUpload: false,
})
const dropArea = this.find('.file-drop-area')
const uplButton = this.find('.uploadBtn.custom-class')
const fileUpl = this.el.querySelector('.file-upload')
dropArea.addEventListener('change', () => {
const existingInput = fileUpl.querySelector('.tpl-imgTitle')
if (!existingInput) {
const inputTitle = ui.create(`
<input eicinput type="text" name="title" class="tpl-imgTitle" placeholder="title of your image" required>
`)
dropArea.appendChild(inputTitle)
// Desabled upload button if input Title empty
uplButton.disabled = true
// Add listener to input Title
inputTitle.addEventListener('input', () => {
const titleValue = inputTitle.value.trim()
if (titleValue) {
inputTitle.classList.remove('highlight')
uplButton.disabled = false
} else {
inputTitle.classList.add('highlight')
uplButton.disabled = true
}
this.fileUpload.options.imageTitle = inputTitle.value
})
}
})
// Event upload button
uplButton.addEventListener('click', () => {
this.fileUpload.onFileAdded.bind(this)
this.addFile()
})
}
// Handle File Added
addFile() {
const fileUpL = this.fileUpload
const uplUrl = fileUpL.options?.uploadUrl
const extension = uplUrl.split('.').pop()
const uplId = fileUpL.options?.uploadId
const fileName = uplId+'.'+extension
let data = {
id: uplId,
name: fileName,
type: extension,
path: '/common',
imageTitle: fileUpL.options?.imageTitle,
meta:'',
}
this.commit(data)
}
commit(data) {
this.result = data
// NE PAS appeler this.cancel() ou this.close() ici
}
showDialogSpinner(show = true) {
let spinner = this.find('.dialog-spinner')
if (!spinner && show) {
spinner = document.createElement('div')
spinner.className = 'dialog-spinner'
spinner.innerHTML = `<span class="eic-spinner"></span> Uploading...`
this.el.appendChild(spinner)
}
if (spinner) spinner.style.display = show ? 'block' : 'none'
}
}
// Register the class
app.registerClass('TemplatesUplImgDialog', TemplatesUplImgDialog)
@@ -0,0 +1,421 @@
<style>
.templateEditor header {
background: url('/app/assets/images/cards/templitorHeader.png') center/cover no-repeat;
}
.templateEditor header h1 {
align-items: center;
border-bottom: 1px solid var(--eicui-base-color-grey-15);
margin-bottom: -1px;
min-height: calc(3 * var(--eicui-base-spacing-m));
padding: calc(var(--eicui-base-spacing-3xl) * 2) var(--eicui-base-spacing-m) var(--eicui-base-spacing-s) var(--eicui-base-spacing-m);
display: block;
color: var(--eicui-base-color-white) !important;
text-shadow: 0 0 4px black;
font-size: x-large !important;
background-size: cover;
transition: all 0.4s;
}
article.templateEditor section{
padding-top: 0;
flex: 1;
}
.eicui-input-container.sticky {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background: white; /* ou autre fond pour lisibilité */
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.templateEditor .template-field {
border: 1px solid var(--eicui-base-color-grey-10);
overflow: auto;
padding: 0.5em;
background-color: #fff;
}
.templateEditor .tplOpStartDate,
.templateEditor .tplOpEndDate {
width: 15em;
}
.tpl-editor .tplActions {
text-align: center;
}
.tpl-editor [eicapp] [eicrichtext] {
padding: var(--eicui-base-spacing-xs);
border: 1px solid var(--eicui-base-color-grey-10);
}
.tpl-editor .template-field.subject label {
font-style: italic;
}
.tpl-editor .file-input {
display: none;
}
.tpl-editor .tpl-edit-area {
width: 60vw;
}
.tpl-editor .path-container {
display: flex;
flex-direction: column;
gap: 5px;
margin-left: 5em;
align-items: flex-start;
}
.tpl-editor .input-button-wrapper {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-start;
width: 100%;
}
.tpl-editor .tplPath {
flex: 1;
min-width: 200px;
}
.tpl-editor .tpl-btn-path {
display: flex;
align-items: center;
justify-content: center;
padding: 5px 10px;
}
.tpl-editor .hidden {
display: none;
}
.tpl-editor .icon-spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.tpl-editor .tpl-spinButton{
border: 0;
}
.tpl-editor .hiddenInput {
position: absolute;
opacity: 0;
pointer-events: none;
width: 0;
}
@keyframes dots {
0% { content: "Loading"; }
33% { content: "Loading ."; }
66% { content: "Loading .."; }
100% { content: "Loading ..."; }
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.tpl-editor input[eicinput]{
height: 2em;
padding-bottom: 0.5em;
}
.tpl-editor .tpl-loadingTools {
font-size: 16px;
font-weight: bold;
color: #555;
animation: blink 1s infinite;
}
.tpl-editor .tpl-loadingTools::after {
content: "Loading";
animation: dots 1.5s infinite steps(1);
display: inline-block;
}
.tpl-editor .delete-img [eicbadge]{
position: relative;
}
.tpl-editor .he-imgSelectContainer{
width: 20%;
}
.tpl-editor .tpl-resizer {
resize: both;
overflow: hidden;
display: inline-block;
max-width: 100%;
}
.tpl-editor .he-imgItem.selected {
border: 2px solid blue;
background-color: lightgray;
}
.tpl-editor .he-imgItem {
transition: background-color 0.3s ease;
}
.tpl-editor .he-imgItem:hover {
background-color: #f0f0f0;
}
.tpl-editor .he-color-palette {
display: grid;
grid-template-columns: repeat(3, 1.5em);
gap: 0.5em;
position: fixed;
border: 1px solid #ccc;
padding: 2px;
background-color: #fff;
z-index: 1000;
}
.tpl-editor .he-colorOption {
width: 1.5em;
height: 1.5em;
cursor: pointer;
pointer-events: auto;
}
.tpl-editor .he-colorOption:hover {
border: 2px solid #000;
}
.tpl-editor .tpl-table {
border-collapse: collapse;
}
.tpl-editor .tpl-table td {
border: 1px solid #ccc;
padding: 10px;
position: relative;
}
.tpl-editor .he-listItems{
min-height: 100px;
}
.tpl-editor .tpl-history {
max-height: 12em;
overflow-y: auto;
}
.tpl-editor .tpl-history .data-set .row {
display: inline-block;
line-height: 1px;
}
.tpl-editor .template-history .footer {
font-size: x-small;
}
.tpl-editor .tpl-fullName,
.tpl-editor .tpl-comment {
display: grid;
}
.tpl-editor .tpl-comment {
white-space: normal;
word-wrap: break-word;
}
.tpl-editor .error-message {
color: red;
padding: 1em;
font-weight: bold;
text-align: center;
}
.tpl-editor .empty-message {
color: #999;
padding: 1em;
text-align: center;
}
.tpl-edit-area {
width: 100%;
position: relative;
}
.eicui-input-container {
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
}
.tpl-edit-area {
position: relative;
width: 100%;
}
.tpl-history-wrapper {
position: absolute;
top: 0;
right: 0;
z-index: 10;
}
.tpl-history-wrapper .toggle-history {
margin-left: auto;
}
.tpl-history {
position: absolute;
top: 2.5em;
right: 0;
width: 15em;
overflow-y: auto;
background: white;
border: 1px solid var(--eicui-base-color-grey-10);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 1000;
opacity: 0;
transform: translateX(100%);
pointer-events: none;
transition: transform 0.3s ease, opacity 0.3s ease;
}
.tpl-history:not(.collapsed) {
transform: translateX(0);
opacity: 1;
pointer-events: auto;
}
.tpl-edit-area .template-field {
max-height: 45vh;
}
@media (max-height: 700px) {
.tpl-edit-area .template-field {
max-height: 55vh;
}
}
@media (min-height: 900px) {
.tpl-edit-area .template-field {
max-height: 65vh;
}
}
.tpl-edit-area .template-field.preview{
max-height: unset;
}
</style>
<article eiccard class="templateEditor">
<header>
<h1>Template Designer</h1>
</header>
<section>
<div class="tpl-editor">
<article eiccard class="tplEditor" tplEditor>
<section>
<div class="cols-3">
<div>
<label small>Template name</label>
<input eicinput class="tplName tpl-inputMeta" name="tplName"
placeholder="your template name..." />
</div>
<div class="cols-2 right">
<div class="tplOpStartDate">
<label small>Operating start date</label>
<input eicinput type="text" class="tpl-formattedDateStart tpl-inputMeta" data-trigger="onStartDateClicked" />
<input type="datetime-local" class="tplStartDate tpl-inputMeta hiddenInput" data-trigger="syncDateInputs"/>
</div>
<div class="tplOpEndDate">
<label small>Operating end date</label>
<input eicinput primary type="text" class="tpl-formattedDateEnd tpl-inputMeta" data-trigger="onEndDateClicked"/>
<input type="datetime-local" class="tplEndDate tpl-inputMeta hiddenInput" data-trigger="syncDateInputs"/>
</div>
</div>
<div class="path-container">
<label small>Template path</label>
<div class="input-button-wrapper">
<span eicchip secondary><label class="tplPath tpl-inputMeta"
name="tplPath"></label></span>
<button eicbutton xsmall rounded primary class="tpl-btn-path"
data-trigger="onPathSearch" icon="icon-folder">
<i class="icon-folder"></i>
</button>
</div>
</div>
</div>
<div class="cols-2 left fields">
<div class="cols-1 left">
<menu small eictab vertical>
<li data-trigger="buildToolButtons" data-value="subject" title="Subject line - Mandatory for email"><label>Subject</label></li>
<li data-trigger="buildToolButtons" data-value="header"><label>Header</label></li>
<li data-trigger="buildToolButtons" data-value="main"><label>Main</label></li>
<li data-trigger="buildToolButtons" data-value="footer"><label>Footer</label></li>
<li data-trigger="buildToolButtons" data-value="altText" title="Plain-text version for email clients that don't support HTML"><label>Text (Alt.)</label></li>
<li data-trigger="buildToolButtons" data-value="preview" class="icon-preview" title="Preview the full template (header + main + footer)"><label>Preview</label></li>
</menu>
</div>
<div class="tpl-edit-area">
<div class="eicui-input-container">
<div class="tpl-loadingTools">Tools</div>
<menu small eictab horizontal class="tpl-toolButtons"></menu>
<div class="tpl-history-wrapper">
<button eicbutton xsmall class="toggle-history icon-history" title="Template history"></button>
<div class="tpl-history collapsed"></div>
</div>
</div>
<div class="template-field subject">
<label>Subject is required for sending mail</label>
<div class="content" contenteditable="true" aria-placeholder=""></div>
</div>
<div class="template-field header">
<label>Header</label>
<div eicrichtext class="content" contenteditable="true"></div>
</div>
<div class="template-field main">
<label>Main</label>
<div eicrichtext class="content" contenteditable="true"></div>
</div>
<div class="template-field footer">
<label>Footer</label>
<div eicrichtext class="content" contenteditable="true"></div>
</div>
<div class="template-field altText">
<label>Text (Alt.)</label>
<div eicrichtext class="content" contenteditable="true"></div>
</div>
<div class="template-field preview" contenteditable="false">
<label> Preview</label>
<div eicrichtext class="content" contenteditable="false"></div>
</div>
</div>
</section>
<footer>
<div class="cols-1 right">
<div class="tplActions">
<button eicbutton small primary class="tool-button he-uplImg hidden"
data-trigger="onUploadImage" title="Upload your own image to insert into your template">
<span>Upload image</span>
</button>
<button eicbutton small primary class="tpl-save hidden" data-trigger="saveHtml">
<span>Save</span>
</button>
<button eicbutton small primary class="tpl-sendTest hidden" data-trigger="onTestSend">
<span>Test mail</span>
</button>
<button eicbutton small primary class="tpl-request hidden" data-trigger="onRequestPub">
<span>Request publishing</span>
</button>
<button eicbutton small primary class="tpl-approved hidden" data-trigger="onApproved">
<span>Approve Template</span>
</button>
<button eicbutton small primary class="tpl-rejected hidden" data-trigger="onRejected">
<span>Reject Template</span>
</button>
</div>
</div>
</footer>
</article>
</div>
</section>
</article>
@@ -0,0 +1,788 @@
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);
@@ -0,0 +1,192 @@
<style>
.templitor > header { background: url('/app/assets/images/cards/templitorHeader.png') center/cover no-repeat; }
.templitor .search-bar{
grid-template-columns: auto min-content;
align-items: baseline;
}
.templitor .library .row {
display: grid;
grid-template-columns: auto 120px 120px;
}
.templitor .library .dataset .row:hover {
background-color: var(--eicui-base-color-grey-5) !important;
box-shadow:
inset 0 2px 4px rgba(0, 0, 0, 0.2),
inset 0 -1px 0 rgba(255, 255, 255, 0.4) !important;
transform: translateY(2px) !important;
}
.templitor .library .dataset .row.selected {
background-color: var(--eicui-base-color-grey-5) !important;
border-left: 5px solid var(--eicui-base-color-info);
box-shadow:
inset 0 2px 4px rgba(0, 0, 0, 0.2),
inset 0 -1px 0 rgba(255, 255, 255, 0.4) !important;
transform: translateY(2px) !important;
}
.templitor .library .dataset .row .cell:nth-child(3),
.templitor .library .dataset .row .cell:nth-child(4) {
text-align: center;
}
.templitor .library.thumbs header,
.templitor .library.thumbs footer { display:none; }
.templitor .library .dataset { max-height: 50vh; }
.templitor .library.thumbs .dataset { max-height: 55vh; }
.templitor .library.thumbs .dataset .row {
display: inline-grid;
width: 320px;
grid-template-columns: none;
border-left: 4px solid var(--app-color-info);
margin: var(--eicui-base-spacing-xs);
background: var(--eicui-base-color-white);
padding: var(--eicui-base-spacing-2xs);
box-shadow: 3px 3px var(--eicui-base-color-grey-20);
border-top: 1px solid var(--eicui-base-color-grey-20);
}
.templitor .tplInfos{
display: inline-block;
}
.templitor .library.thumbs .dataset .row .cell:nth-child(2) {
font-weight: bold;
grid-column: 1 / 3;
}
.templitor .library.thumbs .dataset .row .cell:nth-child(3) { grid-row: 2; grid-column: 1; text-align:left; }
.templitor .library.thumbs .dataset .row .cell:nth-child(4) {
grid-row: 2;
grid-column: 2;
text-align: right;
}
.templitor .tpl-createButton{
float: right;
}
.templitor .tpl-filters {
padding: var(--eicui-base-spacing-s) 0;
display: grid;
grid-auto-flow: column;
grid-gap: var(--eicui-base-spacing-m);
width: fit-content;
}
.templitor .preview {
width: 40vw;
transition: all 0.6s ;
opacity: 1;
}
.templitor .preview[disabled] { width: 0; opacity:0; }
.templitor .preview .html {
border: 1px solid #ccc;
padding: 10px;
border-radius: 5px;
position: inherit;
overflow: auto;
max-height: 50vh;
}
.templitor .preview .html img { max-width: 100%; height: auto; }
.templitor .preview .html table { table-layout: auto; }
.templitor .tpl-path{
display: inline-block;
padding: 0.2em 0.6em;
border-radius: 1em;
/*background-color: var(--eicui-base-color-accent-10);
color: var(--eicui-base-color-black);
*/
background-color: white !important;
color: white !important;
font-size: 0.75rem;
white-space: nowrap;
max-width: calc(100vw * 0.6);
overflow: hidden;
text-overflow: ellipsis;
}
.templitor .preview .html.not-editable::after {
content: 'Not Editable';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-45deg);
font-size: 5rem;
color: rgba(200, 0, 0, 0.1); /* Transparent red */
z-index: 10;
pointer-events: none;
white-space: nowrap;
}
.templitor button[eicuser] span[eicbadge] {
visibility: hidden;
}
.templitor button.loading {
pointer-events: none;
opacity: 0.7;
}
.icon-spinner {
display: inline-block;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.templitor .error-message {
color: red;
padding: 1em;
font-weight: bold;
text-align: center;
}
.templitor .empty-message {
color: #999;
padding: 1em;
text-align: center;
}
.templitor .tpl-filters {
padding: var(--eicui-base-spacing-s) 0;
display: grid;
grid-auto-flow: column;
grid-gap: var(--eicui-base-spacing-m);
width: fit-content;
}
</style>
<article eiccard media class="templitor">
<header>
<h1>Templates Manager</h1>
<h2>Documents & Templates</h2>
</header>
<section>
<article eiccard>
<section>
<div class="cols-2 search-bar">
<select eicselect data-ref="searchCriteria" data-change="onSearchChange" editable data-type="array" placeholder="Search on title, path or user"></select>
<button eicbutton xsmall rounded primary class="tpl-btn-path" data-trigger="onPathBrowse" icon="icon-folder"><i class="icon-folder"></i></button>
</div>
</section>
<section>
<div class="cols-2 right" >
<div class="tpl-filters"></div>
<button class="tpl-createButton" eicbutton small info data-trigger="onTemplateCreate"><span>Create</span></button>
</div>
<div class="cols-2 right">
<div class="library thumbs">
<div class="cols-2 right">
<div eicdatagrid class="templatesList" data-output="searchResults" data-async="results" ></div>
<div class="cols-2">
<button eicbutton rounded small primary data-value="grid" data-trigger="onTemplatesDisplay" icon="icon-list-ul"></button>
<button eicbutton rounded small primary data-value="thumbs" data-trigger="onTemplatesDisplay" icon="icon-table"></button>
</div>
</div>
</div>
<article eiccard class="preview" disabled>
<header>
<div class="cols-2 right">
<div>
<h1>Select a template in the library</h1>
<h2>Template preview</h2>
</div>
<button eicbutton xsmall rounded secondary data-trigger="onPreviewClose" icon="icon-cancel"></button>
</div>
</header>
<section><div class="html"></div></section>
</article>
</div>
</section>
</article>
</section>
</article>
@@ -0,0 +1,561 @@
class TemplatesManagerView extends EICDomContent {
constructor(privileges) {
super('/templates', privileges)
Object.assign(this, app.helpers.basicDialogs)
Object.assign(this, app.helpers.activeAttributes)
Object.assign(this, app.helpers.validators)
this.selectedTemplate = null
this.decodedHtml = null
this.decodedAltText = null
}
DOMContentLoaded(options) {
for (let model in options.models) this[model] = options.models[model]
this.components = ui.eicfy(this.el)
this.gridTemplates = new DataGrid(this.find('.library [eicdatagrid]'), {
headers: [
{ label: 'Name', filter: 'text', sortable: true },
{ label: 'Author', type: 'markup', filter: 'text', sortable: true },
{ label: 'Delete', type: 'button' },
{ label: 'Status', type: 'markup' },
{ label: 'Actions', type: 'markup' },
],
actions: []
})
this.gridTemplates.onRowClick = (row, event) => {
const target = event.target
const isButton = target.closest('.template-actions button')
if (!isButton) {
this.onTemplateSelect(row)
}
}
this.preview = new Card(this.find('.preview'))
this.previewContent = this.find('.preview .html').attachShadow({ mode: 'open' })
this.setupTriggers(this.components)
this.setupRefs(this.components)
this.setupStatusButtons()
this.components.searchCriteria.value = [app.User.identity.uuid]
}
async refreshTpl() {
this.gridTemplates.loading = true
try {
const result = await this.templates.getReadableFolders()
this.authorizedPaths = result.authorizedPaths || []
if (this.authorizedPaths.length === 0) {
this.output('searchResults', `<div style="padding: 2rem; text-align: center;">
<h2 style="color:#b00020;">Access Denied</h2>
<p>You're not authorized to access the templates dashboard</p>
</div>`)
return
}
const tpls = await this.templates.search(
this.components.searchCriteria.value,
this.getSelectedStatus()
)
if (tpls?.message === 'NO_ACCESS') {
this.output('searchResults', `<div style="padding: 2rem; text-align: center;">
<h2 style="color:#b00020;">Access Denied</h2>
<p>You're not authorized to view templates</p>
</div>`)
return
}
this.fillTemplates(tpls)
} catch (error) {
this.output('searchResults', `<div style="padding: 2rem; text-align: center;">
<h2 style="color:#b00020;">Unexpected Error</h2>
<p>An error occurred while loading templates. Please try again later</p>
</div>`)
console.error(error)
} finally {
this.gridTemplates.loading = false
}
}
onSearchChange(component, event) {
event.stopPropagation()
event.preventDefault()
this.refreshTpl()
}
setupStatusButtons() {
this.statusButtons = {}
for (let status in this.templates.getStatusLabel()) {
this.statusButtons[status] = new Button(null, { rounded: true, size: 'xsmall', severity: 'info', label: `<span>${this.templates.getStatusLabel()[status]}</span>` })
this.statusButtons[status].click = this.onStatusChange.bind(this, status)
this.find('.tpl-filters').append(this.statusButtons[status].el)
}
}
onStatusChange(status, event) {
event.stopPropagation()
event.preventDefault()
this.statusButtons[status].severity =
this.statusButtons[status].severity === 'info' ? 'secondary' : 'info'
this.refreshTpl()
}
getSelectedStatus() {
let selected = Object.keys(this.statusButtons).filter(status =>
this.statusButtons[status].severity === 'info'
)
return selected.length > 0 ? selected : Object.keys(this.statusButtons)
}
async onPathBrowse() {
const button = this.find('.tpl-btn-path')
this.showLoading(button)
let result = await this.templates.getReadableFolders()
let { templates, authorizedPaths } = result
if (!Array.isArray(authorizedPaths)) {
authorizedPaths = []
}
if (!authorizedPaths.includes('/templates')) authorizedPaths.unshift('/templates')
this.templates.ffs.authorizedPaths = authorizedPaths
if (!templates.templates) {
templates = { templates }
}
this.templates.ffs.loadStructure(templates || {}, [])
this.templates.ffs.currentPath = '/templates'
this.templates.ffs.authorizedPaths = authorizedPaths
let dialogResult = await this.openDialog(
await this.loadContent(
'templates/Ffs/dialogs/FileBrowserDialog',
{ title: `Select a path...` },
{
ffs: this.templates.ffs,
templates: this.templates.templates,
editable: false,
canManage: false,
startPath: '/templates',
}
)
)
if (dialogResult) {
const criteria = this.components.searchCriteria.value.filter(item => (!item.startsWith('/')))
this.components.searchCriteria.value = [...criteria, dialogResult]
}
this.hideLoading(button)
}
setupStatusButtons() {
this.statusButtons = {}
this.statusCounters = {}
for (let status in this.templates.getStatusLabel()) {
const label = this.templates.getStatusLabel()[status]
// Crée le bouton avec label
const button = new Button(null, {
rounded: true,
size: 'xsmall',
severity: 'info',
label: `<span>${label}</span>`
})
// Create the badge for the count
const badge = new Badge(null, { size: 'xxsmall' })
badge.value = '0'
button.el.append(badge.el)
// Store references to the button and badge
this.statusButtons[status] = button
this.statusCounters[status] = badge
this.statusButtons[status].click = this.onStatusChange.bind(this, status)
this.find('.tpl-filters').append(button.el)
}
}
updateStatusCounts(counts) {
for (let status in this.templates.getStatusLabel()) {
const count = counts[status] || 0
if (this.statusCounters[status]) {
this.statusCounters[status].value = count.toString()
}
}
}
fillTemplates(list) {
this.gridTemplates.clear()
this.gridTemplates.loading = true
let statusCounts = {}
for (let item of list) {
statusCounts[item.status] = (statusCounts[item.status] || 0) + 1
let statusBadge
switch (item.status) {
case 'draft': statusBadge = 'info'; break
case 'created': statusBadge = 'secondary'; break
case 'submitted': statusBadge = 'warning'; break
case 'archived': statusBadge = 'primary'; break
case 'prod': statusBadge = 'success'; break
default: statusBadge = 'danger'
}
let changeBy = ''
let oldest = item.statusHistory.find(() => true)
if (oldest) changeBy = oldest.changedBy
let oldestDate = new Date(oldest.dateTime).toLocaleDateString('fr-FR')
let fullName = changeBy.firstname + ' ' + changeBy.lastname
let statusLabel = item.status === 'prod' ? 'approved' : item.status
const dirs = item.path.split('/').filter(item => item)
const pathChips = dirs.map((dir, idx) => {
const path = dirs.slice(0, idx + 1).join('/')
return (`<span eicchip xxsmall info data-path="/${path}">${dir}</span>`)
}).join('')
let tplInfos = `<span eicchip ${statusBadge} xsmall outline><label>${statusLabel}</label></span>`
let tplTitle = `<div>${item.name}<br/><span xsmall class-"tplInfos">last action: ${oldestDate}<br/>by: ${fullName}</span></div>`
let deleteB = ''
if (item.status === 'created' || item.status === 'draft') {
deleteB = `<div><button eicbutton class="icon-bin delete-tpl" xsmall rounded danger type="button" data-id="${item.id}" data-trigger="onDeleteTemplate"></button><div>`
}
let tplActions = `
<div class="template-actions">
${
item.status !== 'prod'
? `<button eicbutton class="open-btn icon-pen" xsmall rounded primary data-id="${item.id}" type="button" data-trigger="onTemplateEdit" title="Edit this template"></button>`
: ''
}
<button eicbutton class="preview-btn icon-copy" xsmall rounded data-id="${item.id}" type="button" data-trigger="onTemplateClone" title="Clone this template"></button>
</div>
`
let row = this.gridTemplates.addRow(item.id, [pathChips, tplTitle, tplInfos, deleteB, tplActions])
row.dataset.type = 'file'
let previewButton = row.querySelector('[data-trigger="onTemplateSelect"]')
if (previewButton) {
previewButton.addEventListener('click', (event) => {
event.stopPropagation()
this.onTemplateSelect(row)
})
}
let editButton = row.querySelector('[data-trigger="onTemplateEdit"]')
if (editButton) {
editButton.addEventListener('click', (event) => {
event.stopPropagation()
this.onTemplateEdit(item.id)
})
}
let cloneButton = row.querySelector('[data-trigger="onTemplateClone"]')
if (cloneButton) {
cloneButton.addEventListener('click', (event) => {
event.stopPropagation()
this.onTemplateClone(item.id, event.currentTarget)
})
}
let deleteButton = row.querySelector('[data-trigger="onDeleteTemplate"]')
if (deleteButton) {
deleteButton.addEventListener('click', (event) => {
event.stopPropagation()
this.onDeleteTemplate(item.id, item.name)
})
}
}
this.updateStatusCounts(statusCounts)
this.gridTemplates.loading = false
}
async onTemplateSelect(row) {
const currentId = row.dataset.id
if (row.dataset.type == 'file') {
this.find('.preview').removeAttribute('disabled')
this.preview.loading = true
let data = await this.templates.get(currentId)
if (data) {
let rawHtml = data.html || ''
let html = this.replaceMustaches(this.decodeBase64Utf8(rawHtml))
html = html.replace(/[\u200B-\u200D\uFEFF]/g, '')
html = html
.replace(/\u200B/g, '')
.replace(/&ZeroWidthSpace;/gi, '')
this.previewContent.innerHTML = `<style>
span.tpl-token[eicchip] {
border-radius: 60px !important
background-color: var(--eicui-base-color-success-100)
color: var(--eicui-base-color-white)
font-size: 0.75rem
padding: var(--eicui-base-spacing-2xs) var(--eicui-base-spacing-s)
min-height: var(--eicui-base-spacing-xl)
display: inline-grid
grid-template-columns: min-content min-content min-content
min-height: 0
align-items: center
margin: 1px var(--eicui-base-spacing-2xs)
pointer-events: all
white-space: nowrap
position: relative
text-shadow: none
}
</style>\n${html}`
const images = this.previewContent.querySelectorAll('img')
images.forEach((img) => {
img.style.maxWidth = '100%'
img.style.height = 'auto'
})
this.findAll('.templatesList .row.selected').forEach(r => r.classList.remove('selected'))
row.classList.add('selected')
this.find('.preview').removeAttribute('disabled')
this.selectedTemplate = data
row.scrollIntoView({ behavior: 'smooth', block: 'start' })
} else {
this.find('.preview').removeAttribute('disabled')
ui.growl.append('Template not found', 'danger')
}
this.preview.loading = false
}
else if (row.dataset.type == 'folder') this.onFolderSelect(currentId)
}
async onDeleteTemplate(tplId, tplName) {
if (!this.templates.hasPrivilege('delete')) {
ui.growl.append("You do not have permissions to delete this template.", 'danger')
return
}
let result = await this.confirmDialog({
title: 'DELETE template ?',
message: `<p>You are about to permanently delete the template "<b>${tplName}</b>".</p>
<p>Are you sure?</p>`,
okLabel: 'Delete',
severity: 'danger',
okPromise: () => this.templates.delete(tplId)
})
if (result) {
ui.growl.append("Template deleted !", 'success')
this.refreshTpl()
} else {
ui.growl.append("Error deleting Template!", 'error')
}
}
onFolderSelect(path) {
this.templates.ffs.changeDir(path)
this.refreshTpl()
}
async onTemplateClone(tId, button) {
if (!button) return
button.classList.add('spin')
button.disabled = true
try {
const originalTpl = await this.templates.get(tId)
if (!originalTpl) {
ui.growl.append('Original template not found', 'danger')
return
}
const responseCreate = await this.templates.save({ status: 'created' })
if (responseCreate.success) {
const newId = responseCreate.payload.id
const responseClone = await this.createAndSaveNewTemplate(newId, originalTpl)
if (responseClone.success) {
app.Router.route(`/templates/${newId}`)
} else {
ui.growl.append('Error cloning template', 'error')
}
} else {
ui.growl.append('Error creating template', 'error')
}
} catch (error) {
console.error('Error cloning template:', error)
ui.growl.append('Error cloning template', 'error')
} finally {
button.classList.remove('spin')
button.disabled = false
}
}
async onTemplateEdit(tId) {
const button = this.find(`[data-id="${tId}"][data-trigger="onTemplateEdit"]`)
if (!button) return
button.classList.add('spin')
button.disabled = true
try {
app.Router.route(`/templates/${tId}`, { mode: 'edit', ffsData: this.templates.ffs })
} catch (error) {
console.error('Error opening template:', error)
ui.growl.append('Error opening template', 'error')
} finally {
button.classList.remove('spin')
button.disabled = false
}
}
/*Manage template record for new template
@status = created
*/
async onTemplateCreate() {
const button = this.find('.tpl-createButton');
if (!button) return
button.disabled = true;
button.classList.add('loading')
button.innerHTML = '<i class="icon-spinner spin"></i> Creating...'
const responseCreate = await this.templates.save({ status: 'created' })
if (responseCreate.success) {
this.newId = responseCreate.payload.id
const templateStatus = 'created'
const tplDate = new Date().toISOString()
const path = this.authorizedPaths?.[0]
const tplName = 'New template'
const html = btoa('<p>new template</p>')
const altText = ''
const subject = 'New template'
const tplData = {
id: this.newId,
type: 'templateInfo',
name: tplName,
path: path,
html: html,
status: templateStatus,
meta: {
subject: subject,
type: '',
altText: altText,
},
statusHistory: {
changedBy: {
euLoginId: app.User.identity.uuid,
mail: app.User.identity.mail,
role: app.User.identity.role,
},
dateTime: tplDate,
value: templateStatus,
},
startDate: tplDate,
endDate: tplDate,
}
const response = await this.templates.save(tplData)
if (response.success) {
app.Router.route(`/templates/${this.newId}`)
button.disabled = false
button.classList.remove('loading')
button.innerHTML = '<span>Create New Template</span>'
}
else {
ui.growl.append('Error creating template', 'error')
}
}
}
/*Manage template record for clone
@tplData
*/
async createAndSaveNewTemplate(newId, tplData = {}) {
const templateStatus = 'draft'
const tplDate = new Date().toISOString()
const path = tplData.path || this.authorizedPaths?.[0]
const tplName = tplData.name || ' template'
const html = tplData.html || btoa('<p>CLONE template</p>')
const altText = tplData.meta?.altText || ''
const subject = tplData.meta?.subject || ''
const data = {
id: newId,
type: 'templateInfo',
name: 'CLONE-' + tplName,
path: path,
html: html,
status: templateStatus,
meta: {
subject: 'CLONE-' + subject,
type: '',
altText: altText,
},
statusHistory: {
changedBy: {
euLoginId: '',
mail: '',
role: '',
},
dateTime: '',
value: templateStatus,
},
startDate: tplDate,
endDate: tplDate,
}
const response = await this.templates.save(data)
if (response.success) {
return { success: true, id: response.payload.recId }
}
else {
ui.growl.append('Error creating template', 'error')
}
}
getEarliestDateTime(statusHistory) {
if (!Array.isArray(statusHistory)) return null
return statusHistory.reduce((earliest, entry) => {
const entryDate = new Date(entry.dateTime)
return !earliest || entryDate < earliest ? entryDate : earliest
}, null)
}
onPreviewClose() {
this.find('.preview').setAttribute('disabled', '')
}
onTemplatesDisplay(event) {
let button = event._el
switch (button.dataset.value) {
case 'thumbs':
this.find('.library').classList.add('thumbs')
break
default:
this.find('.library').classList.remove('thumbs')
}
}
/**
* replaceMustaches : replaces {{mustache}} with a span with class tpl-token
* @param {string} html : the html to process
* @returns {string} : the processed html
*/
replaceMustaches(html) {
return html.replace(/\{\{(.*?)\}\}/g, function (match, p1) {
return `<span class="tpl-token" eicchip="" success="" data-original="${p1.trim()}">${p1.trim()}</span>`
})
}
decodeBase64Utf8(str) {
return decodeURIComponent(
Array.from(atob(str)).map(c =>
'%' + c.charCodeAt(0).toString(16).padStart(2, '0')
).join('')
)
}
}
app.registerClass('TemplatesManagerView', TemplatesManagerView)