unclean SPARC
This commit is contained in:
@@ -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('"','"').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('"','"'):''}">${field.title}</label>
|
||||
${field.description ? `<span info="" class="icon-info" title="${field.description.replace('"','"').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:'<',
|
||||
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:'>',
|
||||
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);
|
||||
Reference in New Issue
Block a user