unclean SPARC

This commit is contained in:
STEINNI
2025-08-27 07:03:09 +00:00
commit f308460931
430 changed files with 54426 additions and 0 deletions
@@ -0,0 +1,129 @@
<style>
.applicant-dashboard > header {
background: url('/app/assets/images/cards/applicant-dashboard.jpg');
background-position: center;
background-size: cover !important;
background-repeat: no-repeat;
border-bottom: 4px solid white !important;
}
.applicant-dashboard .metrics {
min-width: 320px;
overflow: visible !important;
padding: 0 var(--eicui-base-spacing-m) 0 0;
margin: 0 var(--eicui-base-spacing-s) var(--eicui-base-spacing-s) 0;
}
.applicant-dashboard .list {
display: grid;
grid-gap: var(--eicui-base-spacing-m);
grid-template-columns: repeat(auto-fill, 240px);
}
.applicant-dashboard .members .props {
display: grid;
}
.applicant-dashboard .company menu {
max-height: 280px;
overflow-y: scroll;
}
.applicant-dashboard .company menu label span.pic {
font-size: .75em;
color: var(--eicui-base-color-grey-50);
}
</style>
<article eiccard media class="applicant-dashboard">
<header>
<h1>My EIC</h1>
<h2>Innovation First</h2>
</header>
<section>
<div class="cols-2 left">
<article class="metrics">
<div>
<div class="cols-2 right middle center noflex">
<label>My organisation profile</label>
<div eicdropdown class="company">
<button eicbutton primary xsmall rounded icon="icon-exchange"></button>
<menu eicmenu></menu>
</div>
</div>
<div>
<div xlarge class="company-name"><b></b></div>
<div small class="company-pic"></div>
</div>
<hr />
</div>
<article eiccard class="members" collapsable>
<header>
<h1>Members</h1>
<h2></h2>
</header>
<section>
<ul nonbulleted></ul>
</section>
<footer><div class="cols-1 right"><button eicbutton xsmall primary class="member-add">add member</button></div></footer>
</article>
<article eiccard danger class="activities">
<header>
<h1>TODO List</h1>
</header>
<section>
<ul bulleted></ul>
</section>
</article>
<article eiccard primary>
<header>
<h1>Upcoming events</h1>
</header>
<section>
<ul nonbulleted>
<li class="cols-2 left center"><span class="icon-calendar-failed" danger></span><span>No events foreseen</span></li>
</ul>
</section>
</article>
</article>
<div>
<article eiccard class="fundings" collapsable>
<header>
<h1>My Fundings</h1>
</header>
<section>
<div eicalert info closable>This list only contains projects you have access to. </div>
<div class="list"></div>
</section>
</article>
<article eiccard class="coachings" collapsable collapsed>
<header>
<h1>My Coachings</h1>
</header>
<section>
<div eicalert info closable>This list only contains coachings you have access to. </div>
<div class="list"></div>
</section>
</article>
<article eiccard class="proposals" collapsable>
<header>
<h1>My Proposals</h1>
<h2></h2>
</header>
<section>
<div eicalert info closable>This list only contains proposals you have access to. </div>
<div class="list">
</div>
</section>
<footer>
<div class="cols-1 right">
<div class="cols-2">
<button eicbutton small primary class="proposal-search">search existing proposals</button>
<button eicbutton small primary class="proposal-create">create a new proposal</button>
</div>
</div>
</footer>
</article>
</div>
</div>
</section>
</article>
@@ -0,0 +1,487 @@
class ApplicantDashboardView extends EICDomContent {
constructor() { super(); }
version = 'r2';
DOMContentLoaded(options) {
this.url = options.url;
this.pic = options.pic;
this.profile = options.profile || { admin: true };
for(let model in options.models) this[model] = options.models[model];
let components = ui.eicfy(this.el);
this.orgSelector = components.find(item => item.el.hasAttribute('eicdropdown'))
this.memberAdd = components.find(item => item.el.classList.contains('member-add'))
this.memberAdd.addEventListener('click', this.onMemberAdd.bind(this))
this.proposalCreate = components.find(item => item.el.classList.contains('proposal-create'))
this.proposalCreate.addEventListener('click', this.onProposalCreate.bind(this));
this.proposalSearch = components.find(item => item.el.classList.contains('proposal-search'))
this.proposalSearch.addEventListener('click', this.onProposalSearch.bind(this));
this.fillDashboard(this.pic);
// registering SP Form event triggering
app.events.channel.addEventListener('proposal_updated', this.onProposalUpdate.bind(this))
}
fillDashboard(pic) {
ui.lock();
this.pic = pic;
this.todos = [];
this.memberAdd.disabled = true;
this.proposalCreate.disabled = true;
this.applicant.read(pic).then(this.fill.bind(this));
this.myOrganisations.list().then(this.fillOrganisations.bind(this));
this.members.list(pic).then(this.fillMembers.bind(this));
this.proposals.list(pic).then(this.fillProposals.bind(this));
this.coachings.list(pic).then(this.fillCoachings.bind(this));
ui.unlock();
}
/**
* Updates the registered organisations menu
* @param {*} results
*/
fillOrganisations(results) {
let items = [];
this.orgSelector.menu.clear();
results.sort((a,b) => a.legalName.toLowerCase() > b.legalName.toLowerCase() ? 1: -1)
for(let result of results) {
items.push({
id: result.pic,
label: `${result.legalName} <span class="pic">(${result.pic})</span>`,
icon: "icon-company",
onclick: this.onCompanySwitch.bind(this,result.pic)
});
}
items.push({
label: "Register another organisation",
icon: "icon-plus",
severity: "danger",
onclick: this.onCompanyRegister.bind(this)
});
this.orgSelector.menu.parse(items);
}
/**
* Updates the organisation information
* @param {*} data
*/
fill(data) {
this.find('.company-name b').innerHTML = data.legalName;
this.find('.company-pic').innerHTML = data.pic;
ui.unlock();
}
/**
* Updates the organisation members list
* @param {*} data
*/
fillMembers(data) {
let members = this.find('.members ul');
members.innerHTML = '';
this.find('.members header h2').innerHTML= "";
this.memberAdd.disabled = !this.members.hasPrivilege('add');
let memberMetrics = { active: 0, pending: 0 }
this.todos = this.todos.filter(item => item.scope != 'members');
for(let item of data) {
let actions = [];
switch(item.status) {
case 'active':
break;
case 'pending':
actions.push(new Chip(null, {label:'pending', severity: 'warning', size: 'xsmall'}))
break;
}
memberMetrics[item.status]++;
let li = ui.create(`<li class="cols-2 right middle">
<div>
<div><b>${item.firstname} ${item.lastname}</b></div>
<div xsmall class="props">
${item.position}
${item.email ? `<a href="mailto:${item.email}">${item.email}</a>`:''}
${item.phone ? `<a href="tel:${item.phone}">${item.phone}</a>`:''}
</div>
</div>
<span class="actions"></span>
</li>`);
for(let action of actions)
li.querySelector('.actions').append(action.el);
li.addEventListener('click', this.onMemberDetail.bind(this, item.uid))
members.append(li);
}
this.find('.members header h2').innerHTML = '';
if(memberMetrics.active > 0) {
this.find('.members header h2').append((new Chip(null,{ label: `${memberMetrics.active} active`, size: 'xsmall', severity: 'success'})).el)
}
if(memberMetrics.pending > 0) {
this.find('.members header h2').append((new Chip(null,{ label: `${memberMetrics.pending} pending`, size: 'xsmall', severity: 'warning'})).el)
this.todos.push(
{ scope: 'members', message: `${memberMetrics.pending} member${memberMetrics.pending > 1 ? 's':''} requesting access` }
)
}
this.fillTodos();
}
/**
* Updates the proposals list
* @param {*} data
*/
fillProposals(data) {
/** Proposals list */
this.find('.proposals > header h2').innerHTML = "";
let proposals = this.find('.proposals .list');
proposals.innerHTML = '';
let proposalMetrics = {submitted: 0, draft: 0, accessRequests: 0 };
this.todos = this.todos.filter(item => item.scope != 'proposals');
for(let item of data) {
let card = ui.create(`<article eiccard>
<header>
<h1>${item.acronym || '(unnamed)'}</h1>
<h2>
${item.proposalNumber}
${item.accessRequests > 0 ? `<span eicchip xsmall warning>${item.accessRequests} access request${item.accessRequests > 1 ? 's':''}</span>`:''}
</h2>
</header>
<section>
<span eicchip small primary>short ${item.version}</span>
<span eicchip small info>${item.status}</span>
</section>
<footer>
<div class="cols-2 center noflex"></div>
</footer>
</article>`);
let button = new Button(null, {label:'view', severity: 'primary', size: 'xsmall'});
button.el.addEventListener('click', this.onProposalView.bind(this, item.proposalNumber, item.version))
card.querySelector('footer div').append(button.el)
if(item.version != 'legacy' && item.status == 'draft') {
button = new Button(null, {label:'edit', severity: 'primary', size: 'xsmall'});
button.el.addEventListener('click', this.onProposalEdit.bind(this, item.proposalNumber, item.version))
card.querySelector('footer div').append(button.el)
}
proposalMetrics[item.status]++;
proposalMetrics.accessRequests += item.accessRequests;
proposals.append(card);
}
if(proposalMetrics.draft > 0) {
this.find('.proposals > header h2').append((new Chip(null,{ label: `${proposalMetrics.draft} draft`, size: 'xsmall', severity: 'warning'})).el)
this.todos.push(
{ scope: 'proposals', message: `${proposalMetrics.draft} draft proposal${proposalMetrics.draft > 1 ? 's':''} to be submitted` }
)
}
if(proposalMetrics.accessRequests > 0) {
this.todos.push(
{ scope: 'proposals', message: `${proposalMetrics.accessRequests} proposal access request${proposalMetrics.accessRequests > 1 ? 's':''} pending` }
)
}
this.proposalCreate.disabled = !this.proposals.hasPrivilege('create');
this.fillTodos();
}
/**
* Updates the proposals list
* @param {*} data
*/
fillCoachings(data) {
/** Proposals list */
// this.find('.coachings > header h2').innerHTML = "";
let coachings = this.find('.coachings .list');
coachings.innerHTML = '';
let coachingMetrics = { };
this.todos = this.todos.filter(item => item.scope != 'coachings');
for(let item of data) {
let card = ui.create(`<article eiccard>
<header>
<h1>${item.acronym || '(unnamed)'}</h1>
<h2>
${item.proposalNumber}
</h2>
</header>
<section>
<span eicchip small primary>short ${item.version}</span>
<span eicchip small info>${item.status}</span>
</section>
<footer>
<div class="cols-2 center noflex"></div>
</footer>
</article>`);
let button = new Button(null, {label:'view', severity: 'primary', size: 'xsmall'});
button.el.addEventListener('click', this.onCoachingView.bind(this, item.proposalNumber, item.version))
card.querySelector('footer div').append(button.el)
coachings.append(card);
}
// unused for now
//this.fillTodos();
}
/**
* Updates the TODO list
*/
fillTodos() {
let activities = this.find('.activities ul')
activities.innerHTML = '';
if(this.todos.length > 0) {
ui.show(activities);
for(let activity of this.todos) {
activities.append(ui.create(`<li>${activity.message}</li>`))
}
} else {
ui.hide(activities);
}
}
/**
* Event handler for adding an organisation memnber
* @param {*} event
*/
async onMemberAdd(event) {
event.stopPropagation();
event.preventDefault();
let member = await this.openDialog(
await this.loadContent(
'applicants/dialogs/ApplicantMemberDialog',
{ title: 'Add a member' },
{
model: this.members,
mode: 'create',
pic: this.pic
}
)
);
if(member) {
ui.growl.append('member added', 'success');
this.members.list(this.pic).then(this.fillMembers.bind(this));
}
}
/**
* Event handler for getting organisation member details
* @param {*} event
*/
async onMemberDetail(uid, event) {
event.stopPropagation();
event.preventDefault();
let member = await this.openDialog(
await this.loadContent(
'applicants/dialogs/ApplicantMemberDialog',
{ title: 'edit a member' },
{
model: this.members,
mode: this.members.hasPrivilege('update') ? 'edit': 'read',
pic: this.pic,
uid: uid
}
)
);
if(member) {
if(member.status == 'removed')
ui.growl.append('member removed', 'danger');
else
ui.growl.append('member updated', 'success');
this.members.list(this.pic).then(this.fillMembers.bind(this));
}
}
/**
* Event handler for calling short proposal form on edit
* @param {*} number
* @param {*} version
* @param {*} event
*/
onProposalEdit(number, version, event) {
event.stopPropagation();
event.preventDefault();
app.Router.route(`/organisations/${this.pic}/proposals/${number}`, { mode: 'edit', version: version});
}
/**
*
* @param {*} number
* @param {*} version
* @param {*} event
*/
onProposalView(number, version, event) {
event.stopPropagation();
event.preventDefault();
app.Router.route(`/organisations/${this.pic}/proposals/${number}`, { mode: 'read', version: version});
}
/**
*
* @param {*} number
* @param {*} version
* @param {*} event
*/
onProposalCreate(event) {
event.stopPropagation();
event.preventDefault();
this.proposalCreate.loading = true;
this.proposals.create(this.pic)
.then( async payload => {
let number = payload.proposalNumber;
this.proposalCreate.loading = false;
this.proposals.list(this.pic).then(this.fillProposals.bind(this));
app.Router.route(`/organisations/${this.pic}/proposals/${number}`, { mode: 'edit'});
},
(err)=>{ this.proposalCreate.loading = false })
}
/**
* Event handler for getting organisation member details
* @param {*} event
*/
async onProposalSearch(event) {
event.stopPropagation();
event.preventDefault();
await this.openDialog(
await this.loadContent(
'applicants/dialogs/ApplicantProposalSearchDialog',
{ title: 'search for existing proposals' },
{
model: this.proposals,
pic: this.pic
}
)
);
}
onProposalUpdate(event) {
this.proposals.list(this.pic).then(this.fillProposals.bind(this));
}
/**
*
* @param {*} number
* @param {*} version
* @param {*} event
*/
onCoachingView(number, version, event) {
event.stopPropagation();
event.preventDefault();
this.coachings.read();
}
/**
* Event handler for changing organisation profile
* @param {*} pic
* @param {*} event
*/
onCompanySwitch(pic, event) {
event.stopPropagation();
event.preventDefault();
this.orgSelector.collapse();
ui.lock();
app.User.getBusinessPermissions([
'/organisations',
'/organisations/' + pic,
'/organisations/' + pic + '/members',
'/organisations/' + pic + '/proposals'
], 'Org_Member')
.then(async payload => {
ui.unlock();
if(payload['/organisations/' + pic].permissions.includes('read')) {
this.applicant.setPrivileges('/organisations/{pic}', payload['/organisations/' + pic].permissions);
this.myOrganisations.setPrivileges('/organisations', payload['/organisations'].permissions);
this.members.setPrivileges('/organisations/{pic}/members', payload['/organisations/' + pic + '/members'].permissions);
this.proposals.setPrivileges('/organisations/{pic}/proposals', payload['/organisations/' + pic + '/proposals'].permissions);
this.fillDashboard(pic);
this._controller.changeUrl(this, this.url.replace(':pic', pic))
} else {
ui.unlock();
ui.growl.append('You don\'t have access to this organisation', 'danger' );
}
})
}
/**
* Event handler for registering new organisation
* Using the onboarding as applicant scenario
* @param {*} event
*/
async onCompanyRegister(event) {
event.stopPropagation();
event.preventDefault();
this.orgSelector.collapse();
app.User.getBusinessPermissions(['/organisations'])
.then(async payload => {
let membership = await this.openDialog(
await this.loadContent(
'common/onboarding/dialogs/onboardingApplicantDialog',
{ title: 'Register to an organisation' },
{
models: { user: new onboardingUserModel(payload['/organisations'].permissions) },
cancelLabel: 'cancel'
}
)
);
if(membership) {
ui.growl.append('membership request sent', 'success');
}
},
() => {
ui.growl.append('Sorry, you don\'t have access to this feature', 'danger');
})
}
}
app.registerClass('ApplicantDashboardView', ApplicantDashboardView);
@@ -0,0 +1,48 @@
<div class="applicant-member-dialog">
<div eicalert class="status"></div>
<div class="lookup">
<label>Please identify your member</label>
<input eicinput type="search" data-path="" name="search" value="" placeholder="Enter member's EU Login ID or email and press enter" />
</div>
<div class="member-form">
<div class="cols-3">
<input eicinput type="hidden" name="uid" data-type="ignore" value="" class="required" />
<div>
<label>Title</label>
<select eicselect class="required" name="gender" data-path=""></select>
</div>
<div>
<label>Firstname</label>
<input eicinput disabled value="" class="required" data-path="" name="firstname" data-type="ignore" />
</div>
<div>
<label>Lastname</label>
<input eicinput disabled value="" class="required" data-path="" name="lastname" data-type="ignore" />
</div>
</div>
<div class="cols-2">
<div>
<label>Email</label>
<input eicinput disabled type="email" value="" data-path="" name="email" data-type="ignore" />
</div>
<div>
<label>Phone</label>
<input eicinput type="tel" value="" class="" data-path="" name="phone"/>
</div>
</div>
<div>
<label>Position</label>
<select eicselect lookup class="required" class="required" name="position" data-path=""></select>
</div>
<div>
<label>Rights</label>
<select eicselect data-path="" name="admin" data-type="boolean" class="required">
<option value="false">&lt;h5&gt;User Level&lt;/h5&gt; Can view organisation information, request access or be invited as contributor to proposal(s)</option>
<option value="true">&lt;h5&gt;Admin Level&lt;/h5&gt; Can manage organisation members (add, remove and update) and handle membership requests</option>
</select>
</div>
<div eicalert danger class="confirmation">
<input eiccheckbox type="checkbox" name="confirmDelete" data-type="ignore" label="Yes, I want to remove this member from my organisation" />
</div>
</div>
</div>
@@ -0,0 +1,268 @@
class ApplicantMemberDialog extends EICDialogContent {
actions = [
{
label: 'cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel'
},
{
label: 'remove',
onclick: this.remove.bind(this),
severity: 'danger',
role: 'delete'
},
{
label: 'add',
onclick: this.create.bind(this),
severity: 'primary',
role: 'create'
},
{
label: 'accept',
onclick: this.accept.bind(this),
severity: 'success',
role: 'accept'
},
{
label: 'reject',
onclick: this.revoke.bind(this),
severity: 'danger',
role: 'reject'
},
{
label: 'update',
onclick: this.update.bind(this),
severity: 'primary',
role: 'update'
},
]
statuses = {
active: {
severity: 'success',
label: 'This user is part of your organisation'
},
pending: {
severity: 'warning',
label: 'This user ask to be part of your organisation'
}
}
async DOMContentLoaded(options) {
this.mode = options.mode ? options.mode: 'read';
app.meta.add('organisation-functions', app.Assets.Store.json['organisation-functions']);
app.meta.add('organisation-genders', app.Assets.Store.json['organisation-genders']);
this.model = options.model;
this.pic = options.pic;
let components = ui.eicfy(this.el);
this.uid = components.find(component => component.el.name == 'uid');
this.uid.value = options.uid || '';
this.firstname = components.find(component => component.el.name == 'firstname');
this.lastname = components.find(component => component.el.name == 'lastname');
this.email = components.find(component => component.el.name == 'email');
this.phone = components.find(component => component.el.name == 'phone');
this.gender = components.find(component => component.el.name == 'gender');
this.position = components.find(component => component.el.name == 'position');
this.admin = components.find(component => component.el.name == 'admin');
this.confirmDelete = components.find(component => component.el.name == 'confirmDelete');
this.confirmation = this.find('.confirmation');
this.lookup = this.find('.lookup');
this.status = this.find('.status');
ui.hide(this.status);
ui.hide(this.lookup);
ui.hide(this.confirmation);
app.meta.toOptions('organisation-genders', null, true).forEach(item => this.gender.el.append(item));
app.meta.toOptions('organisation-functions', null, true).forEach(item => this.position.el.append(item));
this.form = new Form(this.find('.member-form'));
this.searchInput = components.find(component => component.el.name == 'search');
this.searchInput.onQuery = this.onUserSearch.bind(this);
}
DOMContentFocused() {
this.actions.find(o => o.role == 'create').button.hide();
this.actions.find(o => o.role == 'delete').button.hide();
this.actions.find(o => o.role == 'update').button.hide();
this.actions.find(o => o.role == 'reject').button.hide();
this.actions.find(o => o.role == 'accept').button.hide();
if(this.mode != 'create') {
this.model.read(this.pic, this.uid.value)
.then( this.fill.bind(this));
} else {
this.setupMode(this.mode);
}
}
fill(data) {
this.uid.value = data.uid;
this.firstname.value = data.firstname;
this.lastname.value = data.lastname;
this.email.value = data.email;
this.phone.value = data.phone;
this.gender.value = data.gender;
this.position.value = data.position;
this.admin.value = data.admin.toString();
this.gender.disabled = false;
this.phone.disabled = false;
this.position.disabled = false;
this.admin.disabled = false;
let status = this.statuses[data.status];
this.status.setAttribute(status.severity, '');
this.status.innerHTML = status.label;
// switch to review mode if user is pending
if(this.mode == 'edit' && data.status == 'pending') this.mode = 'review';
this.setupMode(this.mode)
}
setupMode(mode) {
switch(this.mode) {
case 'read':
ui.show(this.status);
this.gender.disabled = true;
this.phone.disabled = true;
this.position.disabled = true;
this.admin.disabled = true;
break;
case 'edit':
ui.show(this.status);
if(this.uid.value != app.User.identity.uuid) this.actions.find(o => o.role == 'delete').button.show();
this.actions.find(o => o.role == 'update').button.show();
break;
case 'review':
ui.show(this.status);
this.actions.find(o => o.role == 'reject').button.show();
this.actions.find(o => o.role == 'accept').button.show();
break;
case 'create':
ui.show(this.lookup);
ui.hide(this.confirmation);
ui.hide(this.status);
this.actions.find(o => o.role == 'create').button.show();
break;
}
}
onUserSearch() {
this.searchInput.loading = true;
this.uid.value = '';
this.firstname.value = '';
this.lastname.value = '';
this.email.value = '';
this.phone.value = '';
this.position.clear();
this.gender.clear();
this.model.search(this.searchInput.value).then(
(userList => {
this.searchInput.loading = false;
if(userList.length == 0) return
// TODO better manage if several (take most complete?)
let user = userList[0];
this.uid.value = user.uid;
this.firstname.value = user.given_name;
this.lastname.value = user.family_name;
this.email.value = user.email;
}),
(err) => {
if(err.code!=404) { // 404 is just unknown user, the display message as warning (via EIC-model) is enough then.
ui.growl.append('User lookup service is unavailable', 'danger')
}
this.searchInput.loading = false;
}
);
}
create(event) {
if(this.form.validate()) {
this.actions.find(o => o.role == 'create').button.loading = true;
this.actions.find(o => o.role == 'cancel').button.disabled = true;
this.model.add(this.pic, this.uid.value, this.form.value)
.then( this.onSuccess.bind(this), this.onFailed.bind(this))
}
}
update(event) {
if(this.form.validate()) {
this.actions.find(o => o.role == 'update').button.loading = true;
this.actions.find(o => o.role == 'delete').button.disabled = true;
this.actions.find(o => o.role == 'cancel').button.disabled = true;
this.model.update( this.pic, this.uid.value, this.form.value )
.then( this.onSuccess.bind(this), this.onFailed.bind(this))
}
}
remove(event) {
ui.show(this.confirmation);
if(this.confirmDelete.el.checked) {
this.actions.find(o => o.role == 'update').button.disabled = true;
this.actions.find(o => o.role == 'delete').button.loading = true;
this.actions.find(o => o.role == 'cancel').button.disabled = true;
console.log(this.pic, this.uid.value)
this.model.revoke( this.pic, this.uid.value)
.then( this.onSuccess.bind(this), this.onFailed.bind(this))
}
}
revoke(event) {
ui.hide(this.confirmation);
this.actions.find(o => o.role == 'accept').button.disabled = true;
this.actions.find(o => o.role == 'reject').button.loading = true;
this.actions.find(o => o.role == 'cancel').button.disabled = true;
this.model.revoke( this.pic, this.uid.value)
.then( this.onSuccess.bind(this), this.onFailed.bind(this))
}
accept(event) {
this.actions.find(o => o.role == 'reject').button.disabled = true;
this.actions.find(o => o.role == 'accept').button.loading = true;
this.actions.find(o => o.role == 'cancel').button.disabled = true;
this.model.grant( this.pic, this.uid.value, this.form.value)
.then( this.onSuccess.bind(this), this.onFailed.bind(this))
}
onSuccess() {
this.actions.find(o => o.role == 'create').button.loading = false;
this.actions.find(o => o.role == 'update').button.loading = false;
this.actions.find(o => o.role == 'delete').button.loading = false;
this.actions.find(o => o.role == 'accept').button.loading = false;
this.actions.find(o => o.role == 'reject').button.loading = false;
this.actions.find(o => o.role == 'cancel').button.disabled = false;
this.commit(true);
}
onFailed() {
this.actions.find(o => o.role == 'create').button.loading = false;
this.actions.find(o => o.role == 'update').button.loading = false;
this.actions.find(o => o.role == 'delete').button.loading = false;
this.actions.find(o => o.role == 'accept').button.loading = false;
this.actions.find(o => o.role == 'reject').button.loading = false;
this.actions.find(o => o.role == 'cancel').button.disabled = false;
this.abort();
}
}
app.registerClass('ApplicantMemberDialog', ApplicantMemberDialog);
@@ -0,0 +1,22 @@
<style>
.applicant-proposal-search {
min-width: 40vw;
}
.applicant-proposal-search .list .row {
grid-template-columns: 120px auto 120px 120px 200px;
}
.applicant-proposal-search .list .cell:nth-child(2),
.applicant-proposal-search .list .cell:nth-child(4),
.applicant-proposal-search .list .cell:nth-child(5),
.applicant-proposal-search .list .cell:nth-child(6) {
text-align:center
}
</style>
<div class="applicant-proposal-search">
<div class="search-form">
<input eicinput type="search" name="search" placeholder="search on proposal number or acronym" />
</div>
<div class="search-results">
<div eicdatagrid class="list"></div>
</div>
</div>
@@ -0,0 +1,82 @@
class ApplicantProposalSearchDialog extends EICDialogContent {
actions = [
{
label: 'cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'close'
}
]
DOMContentLoaded(options) {
this.components = ui.eicfy(this.el);
this.model = options.model;
this.pic = options.pic;
this.searchInput = this.components.find(component => component.el.name == 'search');
this.searchInput.onQuery = this.search.bind(this);
this.form = new Form(this.find('.search-form'));
this.list = new DataGrid(this.find('.list'), {
height: '240px',
headers: [
{label: 'number'},
{label: 'acronym'},
{label: 'version'},
{label: 'status'},
]
});
this.search();
}
search() {
this.searchInput.loading = true;
this.model.search(this.pic, this.form.value)
.then(this.fill.bind(this));
}
fill(data) {
this.searchInput.loading = false;
this.list.clear();
for(let item of data) {
let row = this.list.addRow(item, [ item.proposalNumber, item.acronym, item.version, item.status ])
switch(item.accessStatus) {
case 'active':
row.querySelector('.cell:last-child').append(ui.create(`<span eicchip small success>contributing</span>`));
break;
case 'pending':
row.querySelector('.cell:last-child').append(ui.create(`<span eicchip small warning>request pending</span>`));
break;
default:
let button = new Button(null, {label: 'request access', size: 'small', severity: 'primary'});
button.el.dataset.number = item.proposalNumber;
button.addEventListener('click', this.requestAccess.bind(this));
row.querySelector('.cell:last-child').append(button.el);
}
}
}
async requestAccess(event) {
event.stopPropagation();
event.preventDefault();
let button = new Button(event.currentTarget);
button.loading = true;
let number = button.el.dataset.number;
this.model.apply(this.pic, number, app.User.identity.uuid)
.then(async payload => {
button.loading = false
this.search();
})
}
}
app.registerClass('ApplicantProposalSearchDialog',ApplicantProposalSearchDialog);
@@ -0,0 +1,97 @@
<style>
.coaching-admin-dashboard .filter { min-height: 380px; }
.coaching-admin-dashboard .metrics { min-width: 240px; }
.coaching-admin-dashboard .coachings .row { grid-template-columns: 106px 1fr 3fr 3fr 2fr 1fr 1fr 88px; }
.coaching-admin-dashboard .organisations .row { grid-template-columns: 1fr 5fr 1fr 48px; }
.coaching-admin-dashboard .organisations .sublist .row { grid-template-columns: 106px 3fr 1fr 2fr 1fr 1fr 88px; }
.coaching-admin-dashboard .coaches .row { grid-template-columns: 1fr 4fr 4fr 1fr 1fr 48px; }
.coaching-admin-dashboard .coachings .row .cell:nth-child(3),
.coaching-admin-dashboard .coachings .row .cell:nth-child(6),
.coaching-admin-dashboard .coachings .row .cell:nth-child(7),
.coaching-admin-dashboard .coachings .row .cell:nth-child(8)
{ text-align: center; }
.coaching-admin-dashboard .organisations .row .cell:nth-child(4)
{ text-align: center; }
.coaching-admin-dashboard .organisations .sublist header .row { background: none;}
.coaching-admin-dashboard .organisations .sublist .row .cell:nth-child(4),
.coaching-admin-dashboard .organisations .sublist .row .cell:nth-child(5),
.coaching-admin-dashboard .organisations .sublist .row .cell:nth-child(6),
.coaching-admin-dashboard .organisations .sublist .row .cell:nth-child(7)
{ text-align: center; }
.coaching-admin-dashboard .coaches .row .cell:nth-child(5),
.coaching-admin-dashboard .coaches .row .cell:nth-child(6)
{ text-align: center; }
</style>
<article eiccard class="coaching-admin-dashboard">
<header>
<h1>Coaching Activities Monitoring</h1>
<h2>(for admins)</h2>
</header>
<section>
<article eiccard class="filter chart">
<section>
<div class="cols-2 right top">
<div eicnodemap></div>
<button eicbutton small rounded primary class="menu icon-search"></button>
</div>
</section>
</article>
<article eiccard class="filter search">
<section>
<div class="cols-2 left top">
<button eicbutton small rounded primary class="menu icon-angle-left"></button>
<div>
<input eicinput type="search" name="q" placeholder="free search" />
<div class="cols-4">
<div>
<label small>cutoff</label>
<select eicselect></select>
</div>
<div>
<label small>status</label>
<select eicselect></select>
</div>
</div>
</div>
</div>
</section>
</article>
<div class="cols-2 left">
<div class="metrics">
<article eiccard danger>
<header>
<h1>TODO List</h1>
</header>
<section>
<ul bulleted xsmall>
<li>Plans need approval</li>
<li>Coaching requests</li>
</ul>
</section>
</article>
</div>
<article eiccard >
<section>
<div class="result-menu tabs-extended">
<menu eictab small badges>
<li>Coachings<span xxsmall eicbadge danger>2</span></li>
<li>Organisations<span xxsmall eicbadge danger>2</span></li>
<li>Coaches<span xxsmall eicbadge danger>1</span></li>
</menu>
</div>
<div class="result coachings">
<div eicdatagrid></div>
</div>
<div class="result organisations">
<div eicdatagrid></div>
</div>
<div class="result coaches">
<div eicdatagrid></div>
</div>
</section>
</article>
</div>
</section>
</article>
@@ -0,0 +1,227 @@
class CoachingAdminDashboardView extends EICDomContent {
DOMContentLoaded(options) {
for(let model in options.models) this[model] = options.models[model];
ui.eicfy(this.el);
this.workflow = new NodeMap(this.find('[eicnodemap]'), {
"orientation": "radial",
"resizable": true,
"allowDrag": true,
"entity": {
"width": 150,
"height": 50,
"gap": 30
}
});
app.Assets.loadJson({name:'workflows/wf-coaching-admin.json'})
.then(this.setupWorflow.bind(this));
//this.workflow.click = this.testBadge.bind(this)
this.filters = new Tab();
this.filters.addTabs(Array.prototype.slice.call(this.findAll('.filter button.menu')).reverse(), this.findAll('.filter'));
this.results = new Tab();
this.results.addTabs(this.findAll('.result-menu li'), this.findAll('.result'));
this.coachingList = new DataGrid(this.find('.coachings [eicdatagrid]'), {
headers: [
{label: 'number', filter: 'text'},
{label: 'type', filter: 'list'},
{label: 'project', filter: 'text'},
{label: 'owner', filter: 'text'},
{label: 'status', filter: 'list'},
{label: 'credits used'},
{label: 'credits left'},
{label: ''}
]
});
this.coachingList.onRowClick = this.onCoachingSelect.bind(this);
this.organisationList = new DataGrid(this.find('.organisations [eicdatagrid]'), {
headers: [
{label: 'pic', filter: 'text'},
{label: 'name', filter: 'text', sortable: true},
{label: 'projects', sortable: true},
{label: ''}
]
});
this.organisationList.onRowClick = this.onOrganisationSelect.bind(this);
this.coachList = new DataGrid(this.find('.coaches [eicdatagrid]'), {
headers: [
{label: 'eu login', filter: 'text'},
{label: 'name', filter: 'text'},
{label: 'email', filter: 'text'},
{label: 'invitations'},
{label: 'coachings'},
{label: ''}
]
});
this.coachList.onRowClick = this.onCoachSelect.bind(this);
this.coachings.getCoachings().then(this.fillCoachings.bind(this));
this.coachings.getCompanies().then(this.fillCompanies.bind(this));
this.coachings.getCoaches().then(this.fillCoaches.bind(this));
}
setupWorflow(data) {
console.log(data)
this.workflow.data = data;
}
fillCoachings(list) {
this.coachingList.clear();
for(let item of list) {
let row = this.coachingList.addRow( item.reference, [
item.number,
item.type,
item.acronym,
item.organisation,
item.status,
item.creditsUsed,
item.creditsLeft,
''
]);
let button = new Button(null, { label: 'refill', severity: 'primary', size: 'xsmall' })
button.el.addEventListener('click', this.onCreditsAdd.bind(this))
row.querySelector('.cell:nth-child(9)').append(button.el)
}
}
fillCompanies(list) {
this.organisationList.clear();
for(let item of list) {
let row = this.organisationList.addRow( item.pic, [
item.pic,
item.legalname,
item.projects.length
]);
let button = new Button(null, { icon: 'icon-angle-down', size: 'xsmall', rounded: true })
button.addEventListener('click', this.onOrganisationProjects.bind(this))
row.querySelector('.cell:nth-child(5)').append(button.el)
}
}
fillCoaches(list) {
this.coachList.clear();
for(let item of list) {
let row = this.coachList.addRow( item.id, [
item.uid,
item.lastname + ' ' + item.firstname,
item.email,
item.invitations,
item.coachings
]);
let button = new Button(null, { icon: 'icon-angle-down', size: 'xsmall', rounded: true })
row.querySelector('.cell:nth-child(7)').append(button.el)
}
}
async onCreditsAdd(event) {
event.stopPropagation();
event.preventDefault();
let result = await this.openDialog(
await this.loadContent(
'coachings/admin/dialogs/CoachingCreditsDialog',
{ title: 'Add coaching credits' },
{ models: { } }
)
);
}
onCoachingSelect(row, event) {
event.stopPropagation();
event.preventDefault();
let id = row.dataset.id;
app.Router.route(`/coachings/coaching/${id}`, { });
}
onOrganisationProjects(event) {
event.stopPropagation();
event.preventDefault();
let row = event.currentTarget.closest('.row');
if(this.currentOrganisationProjects) {
this.currentOrganisationProjects.remove();
}
let projects = ui.create(`
<article eiccard class="sublist" primary>
<section>
<div eicdatagrid footer="hidden"></div>
</section>
</article>
`);
let grid = new DataGrid(projects.querySelector('[eicdatagrid]'), {
headers: [
{label: 'number'},
{label: 'acronym'},
{label: 'type'},
{label: 'status'},
{label: 'credits used'},
{label: 'credits left'},
{label: ''},
]
});
let organisation = this.coachings.getOrganisation(row.dataset.id);
for(let item of organisation.projects) {
console.log(item)
let row2 = grid.addRow( item.reference, [
item.number,
item.acronym,
item.type,
item.status,
item.creditsUsed,
item.creditsLeft,
''
]);
let button = new Button(null, { label: 'refill', severity: 'primary', size: 'xsmall' })
button.el.addEventListener('click', this.onCreditsAdd.bind(this))
row2.querySelector('.cell:nth-child(8)').append(button.el)
}
row.after(projects);
this.currentOrganisationProjects = projects;
}
onOrganisationSelect(row, event) {
event.stopPropagation();
event.preventDefault();
}
onCoachSelect(row, event) {
event.stopPropagation();
event.preventDefault();
let id = row.dataset.id
app.Router.route(`/coachings/coaches/${id}`, { });
}
}
app.registerClass('CoachingAdminDashboardView', CoachingAdminDashboardView);
@@ -0,0 +1,71 @@
<style>
</style>
<article eiccard class="coaching-admin-activity">
<header>
<h1>Coaching for [Project name]</h1>
<h2><span eicchip primary>[status]</span></h2>
</header>
<section>
<div class="cols-2 left">
<div>
<article eicarticle>
<header>
<h1>Project owner</h1>
</header>
<section>
<span small>[company pic]</span>
<span large>[company name]</span>
</section>
</article>
<article eicarticle>
<header>
<h1>Coaching</h1>
</header>
<section>
<label>reference</label>
<span>[coaching id]</span>
<label>selected coach</label>
<span>[coach name]</span>
<label>started</label>
<span>[start date]</span>
<label>status</label>
<span>[coaching status]</span>
<label>credits assigned</label>
<span>[credits]</span>
</section>
</article>
</div>
<div>
<div class="tabs-extended">
<menu>
<li>priorities</li>
<li>plan</li>
<li>timesheet</li>
<li>feedback</li>
</menu>
<section class="panel">
<label>challenge</label>
<div></div>
<label>topics</label>
<div></div>
<label>remarks</label>
<div></div>
</section>
<section class="panel">
<label>objectives</label>
<div></div>
<label>activities</label>
<div></div>
</section>
<section class="panel">
</section>
<section class="panel">
</section>
</div>
</div>
</div>
</section>
</article>
@@ -0,0 +1,12 @@
class CoachingAdminView extends EICDomContent {
DOMContentLoaded() {
ui.eicfy(this.el);
this.tabs = new Tab();
this.tabs.addTabs(this.findAll('menu li'), this.findAll('.panel'));
}
}
app.registerClass('CoachingAdminView', CoachingAdminView);
@@ -0,0 +1,18 @@
<div class="cols-2">
<div>
<label small>Amount of credits</label>
<select eicselect>
<option value="3">3 credits</option>
<option value="6">6 credits</option>
<option value="9">9 credits</option>
<option value="15">15 credits</option>
</select>
</div>
<div>
<label small>Granularity</label>
<select eicselect>
<option value="3">3 credits</option>
<option value="6">6 credits</option>
</select>
</div>
</div>
@@ -0,0 +1,30 @@
class CoachingCreditsDialog extends EICDialogContent {
actions = [
{
label: 'cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel'
},
{
label: 'add',
onclick: this.create.bind(this),
severity: 'primary',
role: 'create'
}
]
DOMContentLoaded(options) {
ui.eicfy(this.el);
}
create() {
}
}
app.registerClass('CoachingCreditsDialog', CoachingCreditsDialog);
@@ -0,0 +1,186 @@
<style>
.coaching-applicant > header {
background: url('/app/assets/images/cards/applicant-dashboard.jpg');
background-position: center;
background-size: cover !important;
background-repeat: no-repeat;
border-bottom: 4px solid white !important;
}
.coaching-applicant .info {
min-width: 240px;
}
</style>
<article eiccard media class="coaching-applicant">
<header>
<h1>My coaching for [proposal_acronym]</h1>
<h2>[coaching_title] <button eicbutton xsmall primary rounded icon="icon-edit"></button></h2>
</header>
<section>
<div class="cols-2 left">
<div class="info">
<article eiccard collapsable>
<header>
<h1>Coaching Summary</h1>
</header>
<section>
<div>
<label small>started</label>
<span><b>12 dec. 2023</b></span>
</div>
<div>
<label small>credits</label>
<span xlarge><b>6 <span small>(3 days)</span></b></span>
</div>
</section>
<footer>
<div class="cols-2 ">
<button eicbutton xsmall danger>abort</button>
<button eicbutton xsmall secondary>request credits</button>
</div>
</footer>
</article>
<article eiccard collapsable>
<header>
<h1>Participants</h1>
<h2><span eicchip xsmall success>2 active</span></h2>
</header>
<section>
<ul nonbulleted=""><li class="cols-2 right middle">
<div>
<div><b>Michael FALLISE</b></div>
<div xsmall class="props">
CFO Chief Financial Officer
<a href="mailto:Michael.FALLISE@ext.ec.europa.eu">Michael.FALLISE@ext.ec.europa.eu</a>
</div>
</div>
<span class="actions"></span>
</li><li class="cols-2 right middle">
<div>
<div><b>Michael SCHWEDA</b></div>
<div xsmall class="props">
Coach
<a href="mailto:Michael.SCHWEDA@ext.ec.europa.eu">Michael.SCHWEDA@ext.ec.europa.eu</a>
</div>
</div>
<span class="actions"></span>
</li>
</ul>
</section>
<footer>
<div class="cols-1 right">
<button eicbutton xsmall primary>add participant</button>
</div>
</footer>
</article>
</div>
<div>
<div eicstatechart></div>
<article eiccard class="panel priorities">
<header>
<h1>Priorities</h1>
<h2><span eicchip xsmall success>priorities defined</span></h2>
</header>
<section>
<div eicalert info>Indicate your challenge and the topics you wish to improve with a coach, for the availability invitations to your shortlist.</div>
<form>
<div>
<label>Biggest Business development challenge</label>
<textarea eictextarea name="challenge" data-name="Challenge" data-type="text" class="required validate-maxlength:1000" placeholder="What is the major challenge for the swift development of your business? (max 1000 chars.)"></textarea>
</div>
<div>
<label>Business development topics</label>
<ul></ul>
</div>
<div>
<label>Any Other question/remark</label>
<textarea eictextarea name="remarks" class="validate-maxlength:200" data-name="Remarks" data-type="text" placeholder="Put any other topic or issue to illustrate your coaching objective. (max 200 chars.)"></textarea>
</div>
</form>
</section>
</article>
<article eiccard class="panel invitations">
<header>
<h1>Coach selection</h1>
<h2><span eicchip xsmall success>coach selected</span></h2>
</header>
<section>
<div>
<menu eictab class="tabs-extended">
<li>step1</li>
<li>step2</li>
</menu>
</div>
<div class="step">
<div eicalert info>
1. Browse coach profiles by matching their experience in industries and markets to your project, add a keyword if you wish.<br/>
2. Check their summaries or CVs and create a shortlist that you will ask for availability to coach with your project.
</div>
<form class="filters form">
<input eicinput type="search" name="q" data-type="text" data-path="Keywords" placeholder="enter a keyword (or coach name)" />
<div class="cols-2 right bottom">
<div>
<label small>Industry domain experience</label>
<select eicselect multiple="multiple" name="industries" data-type="array" data-path="IndustrialDomains/IndustrialDomainRef" class="free-select multiple lookup"></select>
</div>
<button eicbutton primary><i class="icon-search"></i></button>
</div>
</form>
</div>
<div class="step">
<div eicdatagrid class="coaches" footer="hidden"></div>
</div>
</section>
</article>
<article eiccard class="panel plan">
<header>
<h1>Coaching plan</h1>
<h2><span eicchip xsmall warning>plan approval</span></h2>
</header>
<section>
<div eicalert info>
Now that you have selected your coach, (s)he will submit the discussed coaching objectives into the EISMEA coaching system.<br/>
As soon as the approved coaching plan appears in your dashboard, the coach and you can start working together.
</div>
<div>
<label>Your coaching priorities</label>
<div class="challenge">
<label>Your challenge</label>
<span></span>
</div>
<div>
<label>Focused topics</label>
<ul class="topics"></ul>
</div>
<div class="remarks">
<label>remark/question</label>
<span></span>
</div>
</div>
</section>
</article>
<article eiccard class="panel timesheet">
<header>
<h1>Timesheet</h1>
</header>
<section></section>
</article>
<article eiccard class="panel evaluation">
<header>
<h1>Evaluation</h1>
</header>
<section></section>
</article>
<article eiccard class="panel closed">
<header>
<h1>Evaluation</h1>
</header>
<section></section>
</article>
</div>
</div>
</section>
</article>
@@ -0,0 +1,51 @@
class CoachingApplicantView extends EICDomContent {
DOMContentLoaded() {
this.components = ui.eicfy(this.el);
this.panels = this.findAll('.panel');
this.workflow = new StateChart(this.find('[eicstatechart]'), {
"orientation": "linear",
"resizable": true,
"allowDrag": true,
"entity": {
"width": 150,
"height": 50,
"gap": 30
}
});
app.Assets.loadJson({name:'workflows/wf-coaching-applicant.json'})
.then(this.setupWorflow.bind(this));
let invTab = new Tab();
invTab.addTabs(this.findAll('.invitations menu li'), this.findAll('.invitations .step'));
let invList = new DataGrid(this.find('.invitations .coaches'), {
headers: [
{label: 'coach'},
{label: 'email'},
{label: 'status'},
{label: ''},
]
})
invList.addRow(1, [ 'Michael Schweda','michael.schweda@ext.ec.europa.eu', '<span eicchip success>selected</span>', '' ])
invList.addRow(1, [ 'Carmelo Infosino','(not provided)', '<span eicchip danger>declined</span>', '' ])
this.workflow.click = this.onPanelSelect.bind(this);
}
setupWorflow(data) {
this.workflow.data = data;
this.showPanel('plan');
}
onPanelSelect(entity) { this.showPanel(entity.options.data.id) }
showPanel(id) {
for(let panel of this.panels) ui.hide(panel);
ui.show(this.find('.panel.' + id))
}
}
app.registerClass('CoachingApplicantView', CoachingApplicantView);
@@ -0,0 +1,157 @@
<style>
.coaching-coach-dashboard > header {
background: url('/app/assets/images/cards/applicant-dashboard.jpg');
background-position: center;
background-size: cover !important;
background-repeat: no-repeat;
border-bottom: 4px solid white !important;
}
.coaching-coach-dashboard .list {
display: grid;
grid-gap: var(--eicui-base-spacing-m);
grid-template-columns: repeat(auto-fill, 240px);
}
.coaching-coach-dashboard .profile {
min-width: 320px;
max-height: 380px;
overflow: visible !important;
overflow-y: auto !important;
}
</style>
<article eiccard media class="coaching-coach-dashboard">
<header>
<h1>My Coaching activities</h1>
<h2>(for Coaches)</h2>
</header>
<section>
<div class="cols-2 left">
<div>
<article eiccard collapsable collapsed class="profile">
<header>
<h1>My Profile</h1>
<h2>
<span xsmall info danger eicchip>ending 3 march 2024</span>
</h2>
</header>
<section>
<label xsmall>Industrial domains</label>
<div>
<span xsmall info eicchip>Biotechnology</span>
</div>
<label xsmall>Countries of expertise</label>
<div>
<span xsmall info eicchip>Belgium</span>
<span xsmall info eicchip>Germany</span>
<span xsmall info eicchip badge="+2">France</span>
</div>
<label xsmall>Summary</label>
<div small info>
Lorem ipsum sit amet doloris que...
</div>
<label xsmall>keywords</label>
<div>
<span xsmall info eicchip>EIC expert</span>
<span xsmall info eicchip>track record</span>
<span xsmall info eicchip badge="+14">climate tech</span>
</div>
</section>
<footer>
<div class="cols-1 right">
<button eicbutton xsmall primary>Edit my profile</button>
</div>
</footer>
</article>
<article eiccard danger class="todos">
<header>
<h1>TODO List</h1>
</header>
<section>
<ul bulleted>
<li>1 invitation pending</li>
<li>A timesheet has been approved</li>
<li>A timesheet has been rejected</li>
</ul>
</section>
</article>
</div>
<div>
<article eiccard class="invitations" collapsable>
<header>
<h1>Invitations</h1>
<h2>
<span eicchip xsmall warning>1 invitation pending</span>
</h2>
</header>
<section>
<div class="list">
<article eiccard>
<header>
<h1>adasd</h1>
<h2>190200064</h2>
</header>
<section>
<span eicchip xsmall primary>short 1.0</span>
<span eicchip xsmall info>evaluation-finalised</span>
</section>
<footer>
<div class="cols-2 center noflex"><button eicbutton primary xsmall aria-enabled="true" role="button" aria-label="Button"><span>view</span></button></div>
</footer>
</article>
</div>
</section>
</article>
<article eiccard class="coachings" collapsable>
<header>
<h1>Ongoing coachings</h1>
<h2>
<span eicchip xsmall warning>1 timesheet approved</span>
<span eicchip xsmall info>1 evaluated</span>
</h2>
</header>
<section>
<div class="list">
<article eiccard>
<header>
<h1>Betacom</h1>
<h2>192300067</h2>
</header>
<section>
<span eicchip xsmall info>evaluated</span>
</section>
<footer>
<div class="cols-2 center noflex"><button eicbutton primary xsmall aria-enabled="true" role="button" aria-label="Button"><span>view</span></button></div>
</footer>
</article>
</div>
</section>
</article>
<article eiccard class="archives" collapsable collapsed>
<header>
<h1>Past coachings</h1>
<h2></h2>
</header>
<section>
<div class="list">
<article eiccard>
<header>
<h1>Betacom</h1>
<h2>192300067</h2>
</header>
<section>
<span eicchip xsmall info>evaluated</span>
</section>
<footer>
<div class="cols-2 center noflex"><button eicbutton primary xsmall aria-enabled="true" role="button" aria-label="Button"><span>view</span></button></div>
</footer>
</article>
</div>
</section>
</article>
</div>
</div>
</section>
</article>
@@ -0,0 +1,37 @@
class CoachingCoachDashboardView extends EICDomContent {
DOMContentLoaded() {
let profileBt = this.find('.profile button');
profileBt.addEventListener('click', this.onProfileEdit.bind(this));
let coachings = this.findAll('.list button')
for(let button of coachings) {
button.addEventListener('click', this.onCoachingSelect.bind(this));
}
this.components = ui.eicfy(this.el);
}
onProfileEdit(event) {
event.stopPropagation();
event.preventDefault();
let id = 1;
app.Router.route(`/coachings/coaches/${id}`, { });
}
onCoachingSelect(event) {
event.stopPropagation();
event.preventDefault();
//let id = event.dataset.id;
app.Router.route(`/coachings/coaching/1`, { });
}
}
app.registerClass('CoachingCoachDashboardView', CoachingCoachDashboardView);
@@ -0,0 +1,12 @@
<style>
</style>
<article eiccard class="coaching-coach-activity">
<header>
<h1>My Coaching activity</h1>
<h2></h2>
</header>
<section>
</section>
</article>
@@ -0,0 +1,9 @@
class CoachingCoachView extends EICDomContent {
DOMContentLoaded() {
ui.eicfy(this.el)
}
}
app.registerClass('CoachingCoachView', CoachingCoachView);
@@ -0,0 +1,137 @@
<style>
.coach-profile > header {
background: url('/app/assets/images/cards/applicant-dashboard.jpg');
background-position: center;
background-size: cover !important;
background-repeat: no-repeat;
border-bottom: 4px solid white !important;
}
.coach-profile .rating {
width: min-content;
}
</style>
<article eiccard media class="coach-profile">
<header>
<h1>Profile of John-Sebastian Smith</h1>
<h2>
<span eicchip xsmall info>Updated: 16 sep 2023</span>
<span eicchip xsmall danger>Ending: 3 mar 2024</span>
</h2>
</header>
<section>
<div class="tabs-extended">
<menu eictab>
<li data-target="contact">Contact details</li>
<li data-target="profile">Profile</li>
<li data-target="performance">Performance</li>
</menu>
</div>
<div class="panel contact">
<div class="cols-3">
<input eicinput type="hidden" name="uid" data-type="ignore" value="" class="required" />
<div>
<label xsmall>Title</label>
<select eicselect class="required" name="gender" data-path="" disabled></select>
</div>
<div>
<label xsmall>Firstname</label>
<input eicinput disabled value="" class="required" data-path="" name="firstname" data-type="ignore" />
</div>
<div>
<label xsmall>Lastname</label>
<input eicinput disabled value="" class="required" data-path="" name="lastname" data-type="ignore" />
</div>
</div>
<div class="cols-3">
<div>
<label xsmall>EU Login</label>
<input eicinput disabled type="text" value="" data-path="" name="euLogin" data-type="ignore" />
</div>
<div>
<label xsmall>Email</label>
<input eicinput disabled type="email" value="" data-path="" name="email" data-type="ignore" />
</div>
<div>
<label xsmall>Phone</label>
<input eicinput type="tel" value="" class="" data-path="" name="phone" disabled />
</div>
</div>
<div>
<div>
<label xsmall>Address (street, number)</label>
<input eicinput disabled value="" data-path="" name="address" />
</div>
</div>
<div class="cols-2 left">
<div>
<label xsmall>Postcode</label>
<input eicinput disabled value="" data-path="" name="postcode" />
</div>
<div>
<label xsmall>City</label>
<input eicinput disabled value="" class="" data-path="" name="postcode"/>
</div>
</div>
<div>
<label xsmall>Country</label>
<select eicselect disabled class="required" name="country" data-path=""></select>
</div>
</div>
<div class="panel profile">
<label xsmall>Industrial domains</label>
<select eicselect multiple editable disabled>
<option value="Biotechnology" selected>Biotechnology</option>
<option value="Health" selected>Health</option>
<option value="Environment and Earch Sciences" selected>Environment and Earch Sciences</option>
</select>
<label xsmall>Countries of experience</label>
<select eicselect multiple editable disabled>
<option value="fr" selected>France</option>
<option value="ie" selected>Ireland</option>
<option value="da" selected>Denmark</option>
<option value="de" selected>Germany</option>
<option value="be" selected>Belgium</option>
<option value="us" selected>United States of America</option>
</select>
<label xsmall>Executive summary</label>
<textarea eictextarea disabled name="">Fundraising consultant specialised in EIC Accelerator + EIB venture debt in conjunction with private fundraising. 75% success rate in last 2 years on triaged clients. 16 years in EU funding. Laser-sharp, incisive & hands-on. Deep understanding of both EU & start-up worlds and of how public and private funding can leverage each other - see explicit recommendations on LinkedIn.</textarea>
<label xsmall>Keywords</label>
<select eicselect multiple editable disabled>
<option value="EIC expert" selected>EIC expert</option>
<option value="EIB expert" selected>EIB expert</option>
<option value="success rate" selected>success rate</option>
<option value="track record" selected>track record</option>
<option value="EU funding" selected>EU funding</option>
<option value="fundraising" selected>fundraising</option>
<option value="strategy" selected>strategy</option>
<option value="capacity-building" selected>capacity-building</option>
<option value="investment readiness" selected>investment readiness</option>
<option value="research" selected>research</option>
<option value="life sciences" selected>life sciences</option>
<option value="deep tech" selected>deep tech</option>
<option value="climate tech" selected>climate tech</option>
<option value="researcher-entrepreneurs" selected>researcher-entrepreneurs</option>
<option value="female entrepreneurs" selected>female entrepreneurs</option>
<option value="committed" selected>committed</option>
<option value="passionate" selected>passionate</option>
</select>
<label xsmall>Curriculum vitae</label>
<div>
<a href="#">download as PDF</a>
<a href="#">view online</a>
</div>
</div>
<div class="panel performance">
<section class="cols-1 right">
<span eicchip info xxlarge badge="4 ratings" class="rating">4.94</span>
<label xxsmall info>Horizon Europe Rating</label>
</section>
<div eicdatagrid class="evaluations"></div>
</div>
</section>
</article>
@@ -0,0 +1,19 @@
class CoachProfileView extends EICDomContent {
DOMContentLoaded() {
ui.eicfy(this.el);
this.tabs = new Tab();
this.tabs.addTabs(this.findAll('menu li'), this.findAll('.panel'));
this.evaluationList = new DataGrid(this.find('.performance [eicdatagrid]'), {
headers: [
{label: 'project'},
{label: 'rating'}
]
})
}
}
app.registerClass('CoachProfileView', CoachProfileView);
@@ -0,0 +1,105 @@
<div>
<menu>
<li data-target="contact">Contact details</li>
<li data-target="profile">Profile</li>
<li data-target="performance">Performance</li>
</menu>
<div>
<div class="panel contact">
<div class="cols-3">
<input eicinput type="hidden" name="uid" data-type="ignore" value="" class="required" />
<div>
<label>Title</label>
<select eicselect class="required" name="gender" data-path=""></select>
</div>
<div>
<label>Firstname</label>
<input eicinput disabled value="" class="required" data-path="" name="firstname" data-type="ignore" />
</div>
<div>
<label>Lastname</label>
<input eicinput disabled value="" class="required" data-path="" name="lastname" data-type="ignore" />
</div>
</div>
<div class="cols-2">
<div>
<label>Email</label>
<input eicinput disabled type="email" value="" data-path="" name="email" data-type="ignore" />
</div>
<div>
<label>Phone</label>
<input eicinput type="tel" value="" class="" data-path="" name="phone"/>
</div>
</div>
<div>
<div>
<label>Address (street, number)</label>
<input eicinput disabled value="" data-path="" name="address" />
</div>
</div>
<div class="cols-2 left">
<div>
<label>Postcode</label>
<input eicinput disabled value="" data-path="" name="postcode" />
</div>
<div>
<label>City</label>
<input eicinput value="" class="" data-path="" name="postcode"/>
</div>
</div>
<div>
<label>Country</label>
<select eicselect class="required" name="country" data-path=""></select>
</div>
</div>
<div class="panel profile">
<label>Rating (HE)</label>
<div><span eicchip badge="4">4.94</span></div>
<label>Industrial domains</label>
<select eicselect editable disabled>
<option value="Biotechnology" selected>Biotechnology</option>
<option value="Health" selected>Health</option>
<option value="Environment and Earch Sciences" selected>Environment and Earch Sciences</option>
</div>
<label>Countries of experience</label>
<select eicselect editable disabled>
<option value="fr" selected>France</option>
<option value="ie" selected>Ireland</option>
<option value="da" selected>Denmark</option>
<option value="de" selected>Germany</option>
<option value="be" selected>Belgium</option>
<option value="us" selected>United States of America</option>
</div>
<label>Executive summary</label>
<textarea eictextarea name="">Fundraising consultant specialised in EIC Accelerator + EIB venture debt in conjunction with private fundraising. 75% success rate in last 2 years on triaged clients. 16 years in EU funding. Laser-sharp, incisive & hands-on. Deep understanding of both EU & start-up worlds and of how public and private funding can leverage each other - see explicit recommendations on LinkedIn.</textarea>
<label>Keywords</label>
<select eicselect editable disabled>
<option value="EIC expert" selected>EIC expert</option>
<option value="EIB expert" selected>EIB expert</option>
<option value="success rate" selected>success rate</option>
<option value="track record" selected>track record</option>
<option value="EU funding" selected>EU funding</option>
<option value="fundraising" selected>fundraising</option>
<option value="strategy" selected>strategy</option>
<option value="capacity-building" selected>capacity-building</option>
<option value="investment readiness" selected>investment readiness</option>
<option value="research" selected>research</option>
<option value="life sciences" selected>life sciences</option>
<option value="deep tech" selected>deep tech</option>
<option value="climate tech" selected>climate tech</option>
<option value="researcher-entrepreneurs" selected>researcher-entrepreneurs</option>
<option value="female entrepreneurs" selected>female entrepreneurs</option>
<option value="committed" selected>committed</option>
<option value="passionate" selected>passionate</option>
</div>
<label>Curriculum vitae</label>
<div>
<a href="#">download as PDF</a>
<a href="#">view online</a>
</div>
</div>
<div class="panel performance">
<div eicdatagrid class="evaluations"></div>
</div>
</div>
</div>
@@ -0,0 +1,9 @@
class AdminCoachProfileDialog extends EICDomContent {
DOMContentLoaded() {
ui.eicfy(this.el)
}
}
app.registerClass('AdminCoachProfileDialog', AdminCoachProfileDialog);
+40
View File
@@ -0,0 +1,40 @@
<style>
.video-guide > header {
background: url('/app/assets/images/cards/eic_purple_banner.png');
background-position: top;
background-size: contain !important;
background-repeat: no-repeat;
background-color: #5336a3;
border-bottom: 4px solid white !important;
}
.video-guide .list {
display: grid;
grid-gap: var(--eicui-base-spacing-m);
grid-template-columns: repeat(auto-fill, 240px);
}
.video-guide .list section{
text-align: center;
}
.video-guide .list img{
height: 130px;
}
.video-guide .videoStage{
text-align: center;
}
.video-guide video{
max-height: 70vh;
}
</style>
<article eiccard media class="video-guide">
<header>
<h1>${guideMeta.title}</h1>
</header>
<section class="guides">
<div class="videoStage">
<video class="video-guide" controls class="EmbedVideo" width="95%">
<source src="/app/assets/videos/guides/${guideMeta.video}" type="${guideMeta.videoType}">
</video>
</div>
</section>
</article>
+10
View File
@@ -0,0 +1,10 @@
class GuideView extends EICDomContent {
constructor() { super(); }
DOMContentLoaded(options) {
ui.eicfy(this.el)
}
}
app.registerClass('GuideView', GuideView);
+36
View File
@@ -0,0 +1,36 @@
<style>
.welcome-guides > header {
background: url('/app/assets/images/cards/eic_purple_banner.png');
background-position: top;
background-size: contain !important;
background-repeat: no-repeat;
background-color: #5336a3;
border-bottom: 4px solid white !important;
}
.welcome-guides .list {
display: grid;
grid-gap: var(--eicui-base-spacing-m);
grid-template-columns: repeat(auto-fill, 240px);
}
.welcome-guides .list section{
text-align: center;
}
.welcome-guides .list img{
height: 130px;
}
</style>
<article eiccard media class="welcome-guides">
<header>
<h1>Welcome guides</h1>
</header>
<section class="guides">
<div class="list">
</div>
</section>
</article>
+43
View File
@@ -0,0 +1,43 @@
class GuidesListView extends EICDomContent {
constructor() { super(); }
DOMContentLoaded(options) {
ui.eicfy(this.el)
this.guides = options.guides;
this.thumbs = this.find('.guides .list');
this.fillGuides()
}
fillGuides(){
if((app.Assets.Store.json.videoGuides) && app.Assets.Store.json.videoGuides.guides){
let guidesList = app.Assets.Store.json.videoGuides.guides
for(let item of guidesList) {
let tile = ui.create(`<article eiccard>
<header>
<h1>${item.title || ''}</h1>
</header>
<section>
<img src="/app/assets/videos/guides/thumbnails/${item.thumbnail}">
</section>
<footer></footer>
</article>`);
let button = new Button(null, {label:'watch', severity: 'primary', size: 'xsmall'});
button.el.addEventListener('click', this.onGuideWatch.bind(this, item.id))
tile.querySelector('footer').append(button.el)
this.thumbs.append(tile);
}
}
}
onGuideWatch(guideID, event){
event.stopPropagation();
event.preventDefault();
app.Router.route(`/about/guide/${guideID}`, { });
}
}
app.registerClass('GuidesListView', GuidesListView);
+22
View File
@@ -0,0 +1,22 @@
<style>
.helpdesk > header {
background: url('/app/assets/images/cards/eic_purple_banner.png');
background-position: top;
background-size: contain !important;
background-repeat: no-repeat;
background-color: #5336a3;
border-bottom: 4px solid white !important;
}
</style>
<article eiccard media class="helpdesk">
<header>
<h1>Help Desk</h1>
</header>
<section>
<p>For any inquiries regarding EIC Accelerator short proposal submission, we recommend checking out our <a href="https://eic.ec.europa.eu/eic-accelerator-application-platform-frequently-asked-questions_en#short-proposal-step-1" title="FAQ" target="_blank">FAQ section</a> first.</p>
<p>If you need support, have suggestions of improvements, or need to report technical issues and bugs, feel free to reach out to us at <a href="mailto:eic@support.eismea.eu" title="Email for support">eic@support.eismea.eu</a> . </p>
<p>We'll do our best to respond to your message promptly.</p>
<p>Thank you for your interest and feedback, we look forward to help you out!</p>
</section>
</article>
+9
View File
@@ -0,0 +1,9 @@
class HelpDeskView extends EICDomContent {
DOMContentLoaded() {
ui.eicfy(this.el)
}
}
app.registerClass('HelpDeskView', HelpDeskView);
+40
View File
@@ -0,0 +1,40 @@
<style>
.disclaimer > header {
background: url('/app/assets/images/cards/eic_purple_banner.png');
background-position: top;
background-size: contain !important;
background-repeat: no-repeat;
background-color: #5336a3;
border-bottom: 4px solid white !important;
}
.disclaimer > section {
padding: var(--eicui-base-spacing-xxl);
background: var(--app-color-primary);
color: var(--app-color-white) !important;
}
.disclaimer a { color: var(--app-color-white) !important; line-height: 2; }
.disclaimer h2 a { color: var(--app-color-white) !important; line-height: 1; }
.disclaimer .ec-logo {
display: grid;
align-items: center;
}
.disclaimer hr {
opacity: 0.5;
}
</style>
<article eiccard media class="disclaimer">
<header>
<h1>Legal notice</h1>
</header>
<section>
<div>
<ul nonbulleted>
<li><a target="_blank" href="https://eic.ec.europa.eu//history-eic_en">About the EIC</a></li>
<li><a target="_blank" href="https://eismea.ec.europa.eu/">European Innovation Council and Small and Medium-sized Enterprises Executive Agency (EISMEA)</a></li>
<li><a target="_blank" href="https://eic.eismea.eu/community/sites/default/files/data_protection_notice.pdf">Data protection notice</a></li>
<li><a target="_blank" href="https://eic.eismea.eu/community/sites/default/files/cookies.pdf">Cookies</a></li>
</ul>
</div>
</section>
</article>
+9
View File
@@ -0,0 +1,9 @@
class disclaimerView extends EICDomContent {
DOMContentLoaded() {
ui.eicfy(this.el)
}
}
app.registerClass('disclaimerView', disclaimerView);
@@ -0,0 +1,39 @@
<style>
.onboarding.applicant { max-width: 50vw; }
.onboarding.applicant .legalname { font-weight: bold; }
</style>
<div class="onboarding applicant">
<section class="search">
<p>In order to apply, your organisation must be registered withe the European Commission.<br>
Please provide the PIC (Participant Identification Code) of the organisation you are representing.</p>
<div class="cols-2 right top">
<input eicinput type="search" class="required validate-number" data-path="query" hint="The PIC number of a company is at least 9 digit long number" placeholder="Enter a PIC number and press enter..." />
<button eicbutton primary icon="icon-search" class="search"></button>
</div>
</section>
<section class="result">
</section>
<section class="how-to">
If your organisation is not registered yet,
you may apply for the PIC using the<br>
<a href="https://ec.europa.eu/info/funding-tenders/opportunities/portal/participant/register" target="_blank">Participant Register in the</a>
<a href="https://ec.europa.eu/info/funding-tenders/opportunities/portal/participant/register" target="_blank">Funding & Tenders Portal</a>
</section>
<section class="unregistered-pic">
<input type="hidden" data-path="pic" value="" />
<alert eicalert info>This organisation is not registered in the platform.<br/>By continuing the registration process, you will become administrator of this organisation.<br/>The organisation administrator will be notified by email and in charge of accepting or denying any later user registration request on this organisation.</alert>
<section>
<input eiccheckbox type="checkbox" data-path="optin" data-type="ignore" class="required" label="Hereby, I confirm that I hold the legal right to administrate this organisation" value="yes" />
</section>
</section>
<section class="registered-pic">
<input type="hidden" data-path="pic" value="" />
<alert eicalert info>This organisation has already been registered by another user of the platform. In order to complete the registration, an access request will be sent tho the current administrator of the organisation. Once your request validated, you will get access to your organisation.</alert>
<section>
<textarea name="justification" eictextarea class=""
placeholder="You may provide an explaination to the administrator about your access request"></textarea>
<input eiccheckbox type="checkbox" data-path="optin" data-type="ignore" class="required" label="Hereby, I confirm that I have the legal right to access to this organisation" value="yes" />
</section>
</section>
</div>
@@ -0,0 +1,152 @@
class onboardingApplicantDialog extends EICDialogContent {
actions = [
{
label: 'Back to profiles',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel'
},
{
label: 'Register to that organisation',
onclick: this.register.bind(this),
severity: 'success',
role: 'register',
disabled: true
}
]
status = '';
DOMContentLoaded(options) {
for(let model in options.models) this[model] = options.models[model];
if(options.cancelLabel) this.actions.find(o => o.role == 'cancel').label = options.cancelLabel;
this.profile = options.profile;
let components = ui.eicfy(this.el);
this.searchbar = this.find('.search');
this.result = this.find('.result');
this.howTo = new Form(this.find('.how-to'));
this.unregistered = new Form(this.find('.unregistered-pic'));
this.registered = new Form(this.find('.registered-pic'));
this.query = components.find(component => component.el.dataset.path == 'query');
this.query.onQuery = this.onSearch.bind(this);
this.searchButton = components.find(component => component.el.classList.contains('search'));
this.searchButton.el.addEventListener('click', this.onSearch.bind(this));
ui.hide(this.result);
ui.hide(this.registered.el);
ui.hide(this.unregistered.el);
}
onSearch() {
let pic = this.query.value.trim();
if(!pic.match(/^\d{8,}$/)) return;
this.searchButton.loading = true;
ui.hide(this.result);
this.user.searchOrganisation(pic)
.then(this.onResult.bind(this), this.onEmptyResult.bind(this));
}
onResult(payload) {
ui.show(this.result);
this.howTo.hide()
let organisation = payload[0]
this.searchButton.loading = false;
this.isRegistered = organisation.existsInMl
let status = organisation.existsInMl ? 'warning': 'success';
let message = organisation.existsInMl ?
'This company is already registered in the EIC plaform':
'This company is not registered yet the EIC platform';
this.result.innerHTML = `<div class="company" ${status} class="icon-success">
<div><span class="legalname" primary>${organisation.legalName}</span></div>
<div class="cols-3 top">
<div><label>PIC</label><span class="pic">${organisation.pic}</span></div>
<div><label>Country</label><span class="domain">${organisation.country || '(unknown)'}</span></div>
<div><label>Location</label><span class="domain">${organisation.city || '(unknown)'}</span></div>
</div>
</div>
<div eicalert ${status} ${organisation.existsInMl ? 'warning' : 'success' }>${message}</div>`;
this.pic = payload[0].pic;
this.legalname = payload[0].legalName;
this.status = 'selecting'
this.actions.find(o => o.role == 'register').button.disabled = false;
this.actions.find(o => o.role == 'register').button.label = this.isRegistered ? 'Ask to join this organisation' : 'Register to that organisation'
}
onEmptyResult() {
ui.show(this.result);
this.searchButton.loading = false;
this.result.innerHTML = `<div eicalert danger>Sorry, this PIC is invalid</div>`;
this.status = 'selecting';
this.actions.find(o => o.role == 'register').button.disabled = true;
}
onSelect() {
this.findAll('[data-path="pic"]').forEach((item) => item.value = this.query.value )
ui.hide(this.searchbar);
ui.hide(this.result);
this.form = !this.isRegistered ? this.unregistered: this.registered;
ui.show(this.form.el);
this.status = 'completing';
}
register() {
switch(this.status) {
case 'selecting':
this.onSelect();
break;
case 'completing':
if(this.form.validate()) {
this.actions.find(o => o.role == 'register').button.loading = true;
this.actions.find(o => o.role == 'cancel').button.disabled = true;
if(!this.isRegistered) {
this.user.registerOrganisationAccess(this.form.value)
.then( this.onRegisterSuccess.bind(this), this.onFailed.bind(this))
} else {
this.user.requestOrganisationAccess(this.form.value)
.then( this.onRequestSuccess.bind(this), this.onFailed.bind(this))
}
}
break;
}
}
onRegisterSuccess() {
this.actions.find(o => o.role == 'register').button.loading = false;
this.actions.find(o => o.role == 'cancel').button.disabled = false;
this.commit({ request: 'register', pic: this.pic, legalname: this.legalname });
ui.growl.append(`
You have successfully registered to the organisation "${this.legalname}".<br>
You will now be redirected to your dashboard...`,
'success',5000)
setTimeout(()=>{
app.User.logout('/applicant')
}, 1000)
}
async onRequestSuccess() {
this.actions.find(o => o.role == 'register').button.loading = false;
this.actions.find(o => o.role == 'cancel').button.disabled = false;
this.commit({ request: 'join', pic: this.pic, legalname: this.legalname });
}
onFailed() {
this.actions.find(o => o.role == 'register').button.loading = false;
this.actions.find(o => o.role == 'cancel').button.disabled = false;
ui.growl.append(`There was a technical issue trying to join you to the organisation "${this.legalname}".<br>
Please retry again later.`,
'danger',10000)
this.commit(false);
}
}
app.registerClass('onboardingApplicantDialog',onboardingApplicantDialog);
@@ -0,0 +1,39 @@
<style>
.onboarding {}
.onboarding .profiles {
display: grid;
width: auto;
justify-content: center;
grid-auto-flow: column;
grid-gap: var(--app-font-size-m);
}
.onboarding .profiles > article { width: 240px; }
.onboarding, .onboarding .profiles > article h1, .onboarding .profiles > article p { text-align: center; }
.onboarding .exit {
text-align: center;
padding: var(--eicui-base-spacing-m) 0;
}
.onboarding .logout {
margin: 1vw auto;
}
</style>
<div class="onboarding">
<div class="exit">
<div>You are the user: <b>${app.User.identity.uuid}</b></div>
<div>(${app.User.identity.email})</div>
<div class="logout"><a href="#">Sign in with as a different user ?</a></div>
</div>
<div class="profiles">
<article eiccard>
<header>
<h1>I am an Applicant</h1>
</header>
<section>
<p small>I would like to start a short proposal for the EIC accelerator</p>
</section>
<footer>
<div class="cols-1 "><button eicbutton small primary data-type="Applicant">Proceed with that profile</button></div>
</footer>
</article>
</div>
</div>
@@ -0,0 +1,31 @@
class onboardingLandingDialog extends EICDialogContent {
actions = [
]
constructor(options) { super(options); }
DOMContentLoaded(options) {
ui.eicfy(this.el);
let buttons = this.findAll('.profiles button');
for(let button of buttons) button.addEventListener('click', this.onProfileSelect.bind(this));
this.logoutLink = this.find('.onboarding .logout a');
this.logoutLink.addEventListener('click', (event) => {
event.preventDefault()
event.stopPropagation()
app.User.logout()
this.commit()
});
}
onProfileSelect(event) {
event.preventDefault();
event.stopPropagation();
let profile = event.currentTarget.dataset.type;
this.commit(profile);
}
}
app.registerClass('onboardingLandingDialog',onboardingLandingDialog);
@@ -0,0 +1,9 @@
class onboardingLandingView extends EICDomContent {
DOMContentLoaded() {
ui.eicfy(this.el)
}
}
app.registerClass('onboardingLandingView', onboardingLandingView);
@@ -0,0 +1,6 @@
<style>
</style>
<div eicalert success>
Your request to join the organisation "${legalname}" is submited.<br>
You will be notified by email as soon as your access is granted.
</div>
@@ -0,0 +1,21 @@
class onboardingRequestSuccessDialog extends EICDialogContent {
actions = [
{
label: 'Ok',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel'
},
]
constructor(options) { super(options); }
DOMContentLoaded() {
ui.eicfy(this.el)
}
}
app.registerClass('onboardingRequestSuccessDialog', onboardingRequestSuccessDialog);
@@ -0,0 +1,14 @@
<article eiccard>
<header>
<h1>Welcome to the EIC!</h1>
<h2></h2>
</header>
<section>
<p>It appears we could not identify any matching profile in our platform</p>
<p>Please, tell us about yor profile:</p>
<ul>
<li><a href="/onboarding/applicant"></a>I am an applicant</a></li>
<li><a href="https://ec.europa.eu">Sorry, wrong place</a></li>
</ul>
</section>
</article>
@@ -0,0 +1,9 @@
class onboardingLandingView extends EICDomContent {
DOMContentLoaded() {
ui.eicfy(this.el)
}
}
app.registerClass('onboardingLandingView', onboardingLandingView);
@@ -0,0 +1,3 @@
<div>
<span eicalert danger>All your preference settings will be discarded. This operation cannot be undone.</span>
</div>
@@ -0,0 +1,29 @@
class ProfilePreferencesResetDialog extends EICDialogContent {
actions = [
{
label: 'Cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel'
},
{
label: 'Reset',
onclick: this.reset.bind(this),
severity: 'danger',
role: 'reset'
}
]
cancel() {
this.commit(false);
}
reset() {
app.User.preferences = {};
app.User.savePreferences();
this.commit(true);
}
}
app.registerClass('ProfilePreferencesResetDialog',ProfilePreferencesResetDialog);
@@ -0,0 +1,51 @@
<style>
.my-profile > header {
background: url('/app/assets/images/cards/userprefs.jpg');
}
.my-profile .preferences [eicdatagrid] .row {
grid-template-columns: 2fr 1fr;
}
</style>
<article eiccard media class="my-profile">
<header>
<h1>My Profile</h1>
</header>
<section>
<div class="tabs-extended">
<section>
<menu eictab>
<li>User Informations</li>
<li>Preferences</li>
</menu>
</section>
</div>
<article eiccard class="tab-content">
<section>
<div class="cols-2">
<article class="identity" eiccard collapsable>
<header><h1>User informations</h1></header>
<section >
<div eicdatagrid footer="hidden" header="hidden"></div>
</section>
</article>
<article class="roles" eiccard collapsable>
<header><h1>User Roles</h1></header>
<section >
<div eicdatagrid footer="hidden" header="hidden"></div>
</section>
</article>
</div>
</section>
</article>
<article eiccard class="tab-content preferences">
<section>
<div eicdatagrid></div>
</section>
<footer>
<div class="actions cols-1 right">
<button eicbutton small danger class="reset">Reset preferences...</button>
</div>
</footer>
</article>
</section>
</article>
+57
View File
@@ -0,0 +1,57 @@
class myProfileView extends EICDomContent {
DOMContentLoaded() {
ui.eicfy(this.el)
this.tabs = new Tab();
this.tabs.addTabs(this.findAll('menu li'), this.findAll('.tab-content'))
this.gridIdentity = new DataGrid(this.find('.identity [eicdatagrid]'), {
headers: [ { label: ''}, { label: ''} ]
});
this.gridIdentity.addRow('', [ 'First name',app.User.identity.firstname ]);
this.gridIdentity.addRow('', [ 'Last name',app.User.identity.lastname ]);
this.gridIdentity.addRow('', [ 'Email',app.User.identity.email ]);
this.gridIdentity.addRow('', [ 'EU Login',app.User.identity.uuid ]);
this.gridRoles = new DataGrid(this.find('.roles [eicdatagrid]'), {
headers: [ { label: 'Role name', filter: null, sortable: true} ]
});
for(let role of app.User.roles) { this.gridRoles.addRow(role, [ role ]); }
this.gridPreferences = new DataGrid(this.find('.preferences [eicdatagrid]'), {
headers: [
{ label: 'Key', filter: 'text', sortable: true},
{ label: 'Value', filter: 'text', sortable: false},
]
});
this.scanPreferences(app.User.preferences, '')
let resetButton = new Button(this.find('.preferences button.reset'));
resetButton.click = this.onPreferencesReset.bind(this);
}
scanPreferences(tree, path) {
for(const key in tree) {
let item = tree[key];
let newPath = `${path}${ path ? '.': ''}${key}`;
if(typeof item == 'object')
this.scanPreferences(item, newPath)
else
this.gridPreferences.addRow(key, [ newPath, item ] );
}
}
async onPreferencesReset() {
let resetted = await this.openDialog(
await this.loadContent(
'common/profile/dialogs/ProfilePreferencesResetDialog',
{ title: 'Reset your preferences' }, {}
)
);
if(resetted) { this.gridPreferences.clear(); }
}
}
app.registerClass('myProfileView',myProfileView);
+190
View File
@@ -0,0 +1,190 @@
<style>
.support > header {
background: url('/app/assets/images/cards/support.jpg');
background-position-y: center;
}
.support > header h2 {
white-space: normal !important;
margin-right: calc(var(--eicui-base-spacing-4xl) * 3);
width: auto;
display: grid !important;
}
.support > header h2 span {
background: #0000006e;
padding: var(--eicui-base-spacing-2xs);
margin: 0px 220px 0 0;
width: 100%;
box-sizing: border-box;
}
.support-tickets .inspector.loading > section {
min-height: 240px;
}
.support-tickets .inspector.loading::after {
content: "";
display: block;
position: absolute;
width: 100%;
height: 100%;
background: #ffffff9c;
font-family: 'glyphs' !important;
opacity: 0.4;
}
.support-tickets .inspector.loading > section::after {
content: "\\e981";
animation: spin 1s infinite linear;
font-family: 'glyphs' !important;
width: min-content;
height: min-content;
color: var(--eicui-base-color-primary);
font-size: var(--app-font-size-2xl);
z-index: 2;
position: absolute;
top: 50%;
left: 50%;
margin: auto;
}
.support-tickets {
resize: vertical;
height: 42vh;
overflow: hidden;
padding-bottom: var(--eicui-base-spacing-l) !important;
}
.support-tickets .list {
height: 100%;
overflow: hidden;
}
.support-tickets .list [eicdatagrid] {
resize: horizontal;
min-width: 350px;
height: 100%;
display: grid;
}
.support-tickets .list .row { grid-template-columns: 2fr 100px 5em 2em; }
.support-tickets .list .dataset .row { grid-template-columns: 1fr 90px; }
.support-tickets .list .dataset .cell:nth-child(3),.support-tickets .cell:nth-child(4) { text-align: center;}
.support-tickets .list .dataset .cell:nth-child(2) {
grid-column: 1 / 3;
font-weight: bold;
}
.support-tickets .list .dataset .cell:nth-child(4) {
grid-row: 2;
grid-column: 2;
text-align: right;
}
.support-tickets .list .dataset .cell:nth-child(3) {
grid-column: 1;
font-size: smaller;
text-align: left;
}
.support-tickets .inspector {
height: 100%;
display: grid;
}
.support-tickets .inspector .sender { padding: var(--eicui-base-spacing-l) var(--eicui-base-spacing-xs) var(--eicui-base-spacing-xs) var(--eicui-base-spacing-xs); }
.support-tickets .inspector .subject {
background: var(--app-color-white);
padding: var(--eicui-base-spacing-2xs) var(--eicui-base-spacing-xs);
font-weight: bold;
color: var(--app-color-primary);
background: var(--eicui-base-color-grey-5);
}
.support-tickets .inspector .message {
background: var(--eicui-base-color-grey-5);
padding: var(--eicui-base-spacing-m);
}
.support-tickets .inspector .attachments {
display: flex;
flex-direction: row;
flex-wrap: wrap;
background: var(--eicui-base-color-grey-5);
}
.support-tickets .inspector .attachments button { margin: var(--eicui-base-spacing-xs); }
.support-console .row .cell:nth-child(3) { text-align: center;}
.console-error-detail .row { grid-template-columns: 8vw auto; }
.console-error-detail .row .cell:nth-child(2) { font-weight: bold; }
.support-console .row .cell, .console-error-detail .row .cell {
white-space: normal;
text-overflow: clip;
}
.console-error-detail .row .cell .trace{
max-height: 20vw!important;
overflow: auto!important;
}
.support-console .row .cell span.error { color: var(--app-color-danger); }
.support-console .row .cell span.warning { color: var(--app-color-warning); }
.console-error-detail .row .cell span.error { color: var(--app-color-danger); }
.console-error-detail .row .cell span.warning { color: var(--app-color-warning); }
.console-error-detail .row .cell .traceline, .console-error-detail .row .cell .msgdata {
font-family: monospace;
font-size: var(--eicui-base-font-size-s);
color: var(--eicui-base-color-white);
padding: .2em 1em;
border-radius: 5px;
margin-bottom: 1px;
width: fit-content;
}
.console-error-detail .row .cell .traceline {
background-color:var(--eicui-base-color-info-100);
float: right;
clear: both;
}
.console-error-detail .row .cell .msgdata { background-color:var(--eicui-base-color-success-100); }
</style>
<article eiccard media class="support">
<header>
<h1>Help & Support</h1>
<h2 class="cols-2 right">
<span medium>If you require assistance, have suggestions for improvements, or need to report technical issues and bugs, please feel free to contact us by clicking on the "Ask for Support" button, located above. You can also contact us at <a href="mailto:eic@support.eismea.eu" title="contact support"><span accent>eic@support.eismea.eu</span></a>.</span>
<button eicbutton accent large class="create">Ask for support</button>
</h2>
<div class="actions cols-1 right">
</div>
</header>
<section>
<div eicalert info >
For any inquiries regarding EIC Accelerator short proposal submission, we recommend checking out our <a href="https://eic.ec.europa.eu/eic-accelerator-application-platform-frequently-asked-questions_en#short-proposal-step-1" title="FAQ" target="_blank">FAQ section</a> first.</br>
</div>
<div class="cols-2 left support-tickets">
<div class="list">
<div eicdatagrid></div>
</div>
<article eiccard class="inspector">
<header>
<h1></h1>
<h2></h2>
</header>
<section></section>
<footer>
<div class="cols-1 right cta">
<button eicbutton class="reply" small primary>reply</button>
</div>
<form class="reply-form">
<textarea eictextarea name="body" data-path="" class="required"></textarea>
<select eicselect multiple editable name="attachments" data-type="ignore" placeholder="Click here to add a file" hint="You may not attach more than 5Mb of files"></select>
<div class="cols-1 right">
<div class="cols-2">
<button eicbutton class="cancel" small secondary>Cancel</button>
<button eicbutton class="send" data-id="" small primary>Send</button>
</div>
</div>
</form>
</footer>
</article>
</div>
<article class="support-console" eiccard collapsable collapsed>
<header>
<h1>Interface error log</h1>
<h2>Technical errors we spotted while you were using the platform</h2>
</header>
<section>
<div eicdatagrid footer="hidden"></div>
</section>
</article>
</section>
</article>
+358
View File
@@ -0,0 +1,358 @@
class SupportView extends EICDomContent {
TICKET_STATUSES = [
{id: 0, label: '', severity: ''},
{id: 1, label: 'new', severity: 'info'},
{id: 2, label: 'opened', severity: 'warning'},
{id: 3, label: 'opened', severity: 'warning'},
{id: 4, label: 'closed', severity: 'success'},
{id: 5, label: '(state5)', severity: 'danger'},
{id: 6, label: '(state6)', severity: 'danger'},
{id: 7, label: 'opened', severity: 'warning'},
]
constructor() {
super();
Object.assign(this, app.helpers.basicDialogs)
}
DOMContentLoaded(options) {
this.components = ui.eicfy(this.el)
for(let model in options.models) this[model] = options.models[model];
this.gridTickets = new DataGrid(this.find('.support-tickets .list [eicdatagrid]'), {
headers: [
{ label: 'Ticket', filter: 'text', sortable: true},
{ label: 'Modified', type: 'dateTime', sortable: true},
{ label: 'Status', type: 'markup', filter: 'list', sortable: true},
]
});
this.btTicketsRefresh = new Button(null, {icon: 'icon-refresh', severity: 'primary', rounded: true, size: 'xsmall', hint: 'Refresh the list'})
this.btTicketsRefresh.addEventListener('click', this.refreshTicketsList.bind(this));
this.gridTickets.el.querySelector('header .cell.actions').append(this.btTicketsRefresh.el);
this.gridTickets.onRowClick = this.onTicketSelect.bind(this);
this.gridConsole = new DataGrid(this.find('.support-console [eicdatagrid]'), {
headers: [
{ label: 'Time', filter: 'text', sortable: true},
{ label: 'Level', filter: 'list', sortable: true},
{ label: 'Error', filter: 'text', sortable: true},
{ label: 'Url', filter: 'text', sortable: true},
],
actions: [],
height: '30vh'
});
this.gridConsole.onRowClick = this.showErrDetails.bind(this)
this.ticketDetail = this.find('.support-tickets .inspector');
ui.hide(this.ticketDetail);
this.btReply = this.find('.support-tickets .inspector footer .cta button');
this.btReply.addEventListener('click', this.onTicketReplyOpen.bind(this))
this.attachments = this.components.find(item => item.el.name == 'attachments')
this.attachments.activeEditor = BinaryFileContentSelector;
this.replyForm = new Form(this.find('.support-tickets .inspector footer .reply-form'));
ui.hide(this.replyForm.el);
this.replyForm.el.querySelector('button.send').addEventListener('click', this.onTicketReply.bind(this))
this.replyForm.el.querySelector('button.cancel').addEventListener('click', this.onTicketReplyCancel.bind(this))
this.btIssueNew = this.components.find(item => item.el.classList.contains('create'));
this.btIssueNew.addEventListener('click', this.onTicketCreate.bind(this));
this.refreshConsole();
this.refreshTicketsList()
}
DOMContentFocused() {
if(!this.refreshTo) this.refreshTo = setTimeout(this.checkUp2Date.bind(this),500)
this.refreshConsole();
}
DOMContentBlured() {
if(this.refreshTo) {
clearTimeout(this.refreshTo)
this.refreshTo = null
}
}
refreshTicketsList(event=null){
if(event){
event.stopPropagation();
event.preventDefault();
}
this.gridTickets.loading = true;
this.tickets.list()
.then(this.refreshTickets.bind(this));
}
refreshTickets(payload) {
this.gridTickets.loading = false;
this.gridTickets.clear();
let tickets = payload.reverse();
if(tickets.length > 0) {
for(let ticket of tickets) {
let status = this.TICKET_STATUSES.find(item => item.id == (ticket.state.id || 0))
this.gridTickets.addRow(ticket.id, [
`${ticket.title} (#${ticket.number})`,
ticket.state.lastActivity,
`<span eicchip xsmall ${status.severity}>${status.label}</span>`,
], true);
}
this.gridTickets.filter()
this.tickets.get(tickets[0].id)
.then(this.refreshInspector.bind(this));
}
}
clearInspector() {
ui.show(this.ticketDetail, 'grid');
this.ticketDetail.classList.remove('loading');
this.ticketDetail.querySelector('header h1').innerHTML = ''
this.ticketDetail.querySelector('header h2').innerHTML = ''
this.ticketDetail.querySelector('section').innerHTML = '';
this.replyForm.el.querySelector('textarea').innerHTML = '';
this.attachments.clear();
}
freezeInspector() {
ui.show(this.ticketDetail, 'grid');
this.ticketDetail.classList.add('loading');
}
refreshInspector(payload) {
let ticket = payload.ticket;
let articles = payload.articles.reverse();
this.clearInspector();
let content = this.ticketDetail.querySelector('section');
this.ticketDetail.querySelector('header h1').innerHTML = `${ticket.title} (#${ticket.number})`;
this.ticketDetail.querySelector('header h2').innerHTML = ticket.typeofissue || 'General issue';
this.replyForm.el.querySelector('button.send').dataset.id = ticket.id;
let lastDate = null;
for(let article of articles) {
let message = article.body
.split("\n").join('<br/>')
.split(' -> ').join(' &#8594; ')
let currentDate = ui.format.date( article.created )
if(lastDate != currentDate ) {
content.append(ui.create(`<div class="center" small>${currentDate}</div>`))
lastDate = currentDate;
}
switch(article.sender) {
case 'Agent':
case 'System':
content.append(ui.create(`<div class="cols-1 left sender"><span eicchip primary xsmall><i class="icon-user"></i><span>EIC support wrote</span></span></div>`))
break;
case 'Customer':
content.append(ui.create(`<div class="cols-1 right sender"><span eicchip success xsmall><i class="icon-user"></i><span>You wrote</span></span></div>`))
break;
}
let thread = ui.create(`<div class="content"><div class="subject">${article.subject || ''}</div><div class="message">${message}</div><div class="attachments"></div></div>`)
content.append(thread)
for(let file of article.attachments) {
let button = new Button(null, {
label: file.filename,
severity: 'info',
size: 'xsmall',
rounded: true,
icon: 'icon-attachment'
});
button.el.dataset.ticket = ticket.id;
button.el.dataset.article = article.id;
button.el.dataset.attachment = file.id;
button.el.addEventListener('click', this.onFileOpen.bind(this));
thread.querySelector('.attachments').append(button.el)
}
}
}
refreshConsole() {
this.gridConsole.clear();
for(let err of app.latestErrors) {
this.gridConsole.addRow(err.errId,
[ err.timestamp,
`<span class="${err.level.toLowerCase()}">${err.level}</span>`,
err.message,
err.url,
]
);
}
}
checkUp2Date() {
let mustRefresh = false
if(app.latestErrors.lengh != this.gridConsole.length) mustRefresh=true
else {
for(let err of app.latestErrors) {
if(!this.gridConsole.getRowById(err.errId)) { // err not in grid (the opposite should not happen)
mustRefresh = true
break
}
}
}
if(mustRefresh) this.refreshConsole()
this.refreshTo = setTimeout(this.checkUp2Date.bind(this),500)
}
onFileOpen(event) {
event.stopPropagation();
event.preventDefault();
let ticketId = event.currentTarget.dataset.ticket;
let articleId = event.currentTarget.dataset.article;
let attachmentId = event.currentTarget.dataset.attachment;
this.tickets.download(ticketId, articleId, attachmentId);
}
onTicketSelect(target) {
this.closeReplyForm();
this.freezeInspector();
this.tickets.get(target.dataset.id)
.then(this.refreshInspector.bind(this));
}
async onTicketCreate(event) {
event.stopPropagation();
event.preventDefault();
let ticket = await this.openDialog(
await this.loadContent(
'common/support/dialogs/SupportIssueFormDialog',
{ title: 'Report an issue' },
{
mode: 'create',
models: { tickets: this.tickets }
}
)
);
if(ticket) {
this.tickets.list()
.then(this.refreshTickets.bind(this));
}
}
closeReplyForm() {
ui.show(this.ticketDetail.querySelector('footer .cta'), 'grid');
ui.hide(this.ticketDetail.querySelector('footer .reply-form'));
}
openReplyForm() {
ui.hide(this.ticketDetail.querySelector('footer .cta'));
ui.show(this.ticketDetail.querySelector('footer .reply-form'), 'grid');
}
onTicketReplyOpen(event) {
event.stopPropagation();
event.preventDefault();
this.openReplyForm();
}
async onTicketReply(event) {
event.stopPropagation();
event.preventDefault();
let ticketId = event.currentTarget.dataset.id;
if(this.replyForm.validate()) {
let payload = this.replyForm.value;
payload.attachments = this.attachments.selectedItems.map(item => { return { filename: item.label, "mime-type": item.type, data: item.content} })
let size = JSON.stringify(payload).length
let limit = 1024 * 1024 * 5;
if(size < limit) {
this.tickets.update(ticketId, payload)
.then( this.onReplySuccess.bind(this, ticketId))
} else {
ui.growl.append('Your message is exceeding the allowed 5Mb size limit. Please consider removing files or reduce file size', 'danger')
}
}
}
onReplySuccess(id, payload) {
this.replyForm.el.querySelector('textarea').value = '';
this.attachments.clear();
this.closeReplyForm();
this.tickets.list().then( this.refreshTickets.bind(this));
this.tickets.get(id).then(this.refreshInspector.bind(this));
}
onTicketReplyCancel(event) {
event.stopPropagation();
event.preventDefault();
this.closeReplyForm();
}
async showErrDetails(row, event) {
let err = app.latestErrors.find(err=>err.errId==row.dataset['id'])
if(!err) return
let trace = '<div class="trace">'
for(let step of err.stacktrace) {
if(step[2].length<2) continue
trace += `<div class="traceline">${step[0]} @ ${step[1]} ${step[2].join(':')}</div>`
}
trace += '</div>'
let messageData = ''
if(Array.isArray(err.messageData)) {
messageData = '<div class="msgdata">'+err.messageData.map(x=>JSON.stringify(x)).join('</div><div class="msgdata">')+'</div>'
} else if(err.messageData) messageData = '<div class="msgdata">'+err.messageData+'</div>'
let result = await this.confirmDialog({
title: 'Error Details',
message: `<div eicdatagrid class="console-error-detail" footer="hidden" header="hidden"></div>`,
cancelLabel: 'Close',
okLabel: '',
severity: 'primary',
muted: true,
okPromise:() => { },
contentLoaded: (el => {
el.style['max-width'] = '80vw'
el.style['max-height'] = '50vw'
this.gridEntry = new DataGrid(el.querySelector('.support-entryGrid'), {
headers: [
{ label: ''},
{ label: ''}
],
height: 'auto'
});
this.gridEntry.addRow('time', ['Time', err.timestamp])
this.gridEntry.addRow('level', ['Level', `<span class="${err.level.toLowerCase()}">${err.level}</span>`])
this.gridEntry.addRow('message', ['Message', `<span class="${err.level.toLowerCase()}">${err.message}</span><br>${messageData}`])
this.gridEntry.addRow('user', ['User UID', err.user.identity.uuid])
this.gridEntry.addRow('user', ['User Roles', err.user.roles.join(', ')])
this.gridEntry.addRow('url', ['Url', err.url])
this.gridEntry.addRow('stacktrace', ['Trace', trace])
}).bind(this),
})
}
}
app.registerClass('SupportView',SupportView);
@@ -0,0 +1,23 @@
<style>
.support-issue-form {
min-width: 50vw;
min-height: 50vh;
}
.support-issue-form [name="body"] {
min-height: 180px;
}
</style>
<div class="support-issue-form">
<label xsmall>Subject</label>
<input eicinput type="text" name="title" data-path="" value="" class="required" placeholder="Please describe shortly your issue" />
<label xsmall>Type of inquiry</label>
<select eicselect name="typeofissue" tabindex="" data-path="" class="required" placeholder="Please select a category">
<option value=""></option>
<option value="question">I have a general question</option>
<option value="accessRequest">I cannot connect to the platform</option>
<option value="bug">I am facing a technical issue</option>
</select>
<label xsmall>Description</label>
<textarea eictextarea name="body" data-path="article" class="required" placeholder="Please provide as many information as possible"></textarea>
<select eicselect multiple editable name="attachments" data-type="ignore" placeholder="Click here to add a file" hint="You may not attach more than 5Mb of files"></select>
</div>
@@ -0,0 +1,61 @@
class SupportIssueFormDialog extends EICDialogContent {
actions = [
{
label: 'Cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel'
},
{
label: 'Send',
onclick: this.send.bind(this),
severity: 'primary',
role: 'send',
disabled: false
}
]
DOMContentLoaded(options) {
this.mode = options.mode || 'create';
this.ticketId = options.ticketId || 0;
for(let model in options.models) this[model] = options.models[model];
this.components = ui.eicfy(this.el)
this.form = new Form(this.el);
this.attachments = this.components.find(item => item.el.name == 'attachments')
this.attachments.activeEditor = BinaryFileContentSelector;
}
send() {
if(this.form.validate()) {
let payload = this.form.value;
payload.article.attachments = this.attachments.selectedItems.map(item => { return { filename: item.label, "mime-type": item.type, data: item.content} })
let size = JSON.stringify(payload).length
let limit = 1024 * 1024 * 5;
if(size < limit) {
this.tickets.create(payload)
.then( this.success.bind(this));
} else {
ui.growl.append('Your message is exceeding the allowed 5Mb size limit. Please consider removing files or reduce file size', 'danger')
}
}
}
success(payload) {
this.commit(payload)
ui.growl.append(`We'll do our best to respond to your message promptly.</br>
Thank you for your interest and feedback, we look forward to help you out!`, 'success')
}
}
app.registerClass('SupportIssueFormDialog',SupportIssueFormDialog);
@@ -0,0 +1,85 @@
<style>
.mailings{
--tile-width: 320px;
--tile-height: 450px;
--chart-height: 110px;
}
.mailings > header {
background: url('/app/assets/images/cards/mass-mailer.jpg');
}
.mailings .search-bar{
grid-template-columns: auto min-content;
align-items: baseline;
}
.mailings .path label {
white-space: nowrap;
}
.mailings .list {
display: grid;
grid-template-columns: repeat(auto-fill, var(--tile-width));
grid-gap: var(--eicui-base-spacing-m);
}
.mailings .list .tile {
width: var(--tile-width);
height: var(--tile-height);
overflow: visible !important;
}
.mailings .list .tile h1{
max-width: calc(var(--tile-width) - 70px);
}
.mailings .results .filters {
padding: var(--eicui-base-spacing-s) 0;
display: grid;
grid-auto-flow: column;
grid-gap: var(--eicui-base-spacing-m);
width: fit-content;
}
.mailings [eicpiechart] {
height: var(--chart-height);
margin-top: -15px;
}
.mailings .tile .path{
min-height: calc(var(--eicui-base-font-size-2xs)*3);
}
.mailings .results section{
min-height: 6em;
}
.mailings div.results-loader {
width:1em;
display:none;
}
.mailings .error-message {
color: red;
padding: 1em;
font-weight: bold;
text-align: center;
}
.mailings .empty-message {
color: #999;
padding: 1em;
text-align: center;
}
</style>
<article eiccard media class="mailings massMailerDashboard">
<header>
<h1>Mailings Monitoring & Control</h1>
<h2>Communication Services</h2>
</header>
<section>
<div class="cols-2 search-bar">
<select eicselect data-ref="searchCriteria" data-change="onSearchChange" editable data-type="array" placeholder="Search on title, path or user"></select>
<button eicbutton="" xsmall="" rounded="" primary data-trigger="onPathBrowse" icon="icon-folder"><i class="icon-folder"></i></button>
</div>
<article eiccard class="results" data-ref="resultsPane">
<section>
<div class="cols-2 right">
<div class="filters"></div>
<button eicbutton data-trigger="onMailingCreate" primary >Create</button>
</div>
<div class="list" data-output="searchResults" data-async="results"></div>
</section>
</article>
</section>
</article>
@@ -0,0 +1,290 @@
class MailingDashboardView extends EICDomContent {
constructor() {
super()
Object.assign(this, app.helpers.activeAttributes)
this.selectedMailingId = null
this.tileMarkup = app.Assets.Store.html['/app/assets/html/mailing/tile.html']
}
DOMContentLoaded(options) {
for(let model in options.models) this[model] = options.models[model]
const components = ui.eicfy(this.el)
this.setupTriggers(components)
this.setupRefs(components)
this.setupStatusButtons()
this.initializeData()
}
DOMContentFocused(options) {
// Avoid 2nd refesh on DomContentLoaded
if(this.wasBlured){ this.refreshSearch() }
this.wasBlured = false
}
DOMContentBlured(options) { this.wasBlured = true }
async initializeData() {
const result = await this.mailings.getReadableFolders()
this.authorizedPaths = result.authorizedPaths || []
if (this.authorizedPaths.length === 0) {
this.output('searchResults', `<div style="padding: 2rem; text-align: center;">
<h2 style="color:#b00020;">Access Denied</h2>
<p>You're not authorized to access to mailing dashboard</p>
</div>`)
return
}
this.components.searchCriteria.value = [app.User.identity.uuid]
}
setupStatusButtons(){
this.statusButtons = {}
this.statusCounters = {}
for(let status in this.mailings.getStatusLabel()){
this.statusButtons[status] = new Button(null, { rounded: true, size: 'xsmall', severity: 'info', label: `<span>${this.mailings.getStatusLabel()[status]}</span>`})
this.statusButtons[status].click = this.onStatusChange.bind(this, status)
this.statusCounters[status] = new Badge(null, {size: 'xxsmall'})
this.statusButtons[status].el.append(this.statusCounters[status].el)
this.find('.filters').append(this.statusButtons[status].el)
}
}
onStatusChange(status, event){
event.stopPropagation()
event.preventDefault()
this.statusButtons[status].severity = (this.statusButtons[status].severity == 'secondary') ? 'info' : 'secondary'
if(this.getSelectedStatus().length == 0) { // turning off last status resets them all (no-status is meaningless)
for(let status in this.statusButtons) this.statusButtons[status].severity = 'info'
}
this.refreshSearch()
}
onOneStatus(event){
event.stopPropagation()
event.preventDefault()
for(let status in this.statusButtons){
this.statusButtons[status].severity = (status == event.target.dataset.status) ? 'info' : 'secondary'
}
this.refreshSearch()
}
onSearchChange(component, event){
event.stopPropagation()
event.preventDefault()
this.refreshSearch()
}
async refreshSearch(){
this.output('searchResults','')
this.setAsyncLoading(true)
const mailings = await this.mailings.search(this.components.searchCriteria.value, this.getSelectedStatus())
this.setAsyncLoading(false)
this.fillResults(mailings)
}
getSelectedStatus(){
return(
Object.keys(this.mailings.getStatusLabel()).reduce( (acc, status) => {
if(!this.statusButtons[status].el.hasAttribute('secondary')) acc.push(status)
return(acc)
}, [] )
)
}
updateStatusCounts(counts){
for(let status in this.mailings.getStatusLabel()){
this.statusCounters[status].value = (status in counts) ? counts[status] : 0
}
}
fillResults(mailings){
this.output('searchResults', '')
if (!this.authorizedPaths || this.authorizedPaths.length === 0) {
this.output('searchResults', `<div style="padding: 2rem; text-align: center;">
<h2 style="color:#b00020;">Access Denied</h2>
<p>You're not authorized to access to view this mailing</p>
</div>`)
return
}
mailings = mailings.filter(mailing =>
this.authorizedPaths.some(path => mailing.path.startsWith(path))
)
let statusCounts = {}
if (mailings.length === 0) {
this.output('searchResults', `<div class="empty-message">No mailings found for your search and access rights.</div>`)
this.updateStatusCounts(statusCounts)
return
}
for(let mailing of mailings){
if(mailing.status in statusCounts) statusCounts[mailing.status]++
else statusCounts[mailing.status] = 1
const lastStatus = this.mailings.getLatestStatus(mailing)
const dirs = mailing.path.split('/').filter(item=>item)
const pathChips = dirs.map((dir, idx) => {
const path = dirs.slice(0, idx+1).join('/')
return(`<span eicchip xxsmall info data-path="/${path}">${dir}</span>`)
}).join('')
const tile = ui.create(Controller.processTemplate('resultTile',
this.tileMarkup,
{ mid: mailing.id,
name: mailing.name ? mailing.name : 'Untitled',
status: mailing.status,
statusLabel: this.mailings.getStatusLabel()[mailing.status],
path: mailing.path,
pathChips: pathChips,
lastUpdate: (new Intl.DateTimeFormat("fr-FR", {timeZone: "Europe/Paris", dateStyle:'medium', timeStyle:'medium'})).format(new Date(lastStatus.dateTime)), //'23 Aug 2024 16:48:01',
lastContributor: `${lastStatus.changedBy.firstname} ${lastStatus.changedBy.lastname}`,
kpis: {
nbRecipients: mailing.nbRecipients || 0,
nbPending: (mailing.status=='sent') ? mailing.nbRecipients - mailing.kpis.bouncesCount -mailing.kpis.readCount : 'N/A',
nbBounced: (mailing.status=='sent') ? mailing.kpis.bouncesCount : 'N/A',
nbReached: (mailing.status=='sent') ? mailing.kpis.readCount : 'N/A',
},
}
))
if(!mailing.nbRecipients || (mailing.nbRecipients.length==0)) {
tile.querySelector('[data-trigger="onRecipientsList"]').closest('li[menuitem]').setAttribute('disabled','')
}
if(!mailing.kpis.bouncesCount || (mailing.kpis.bouncesCount.length==0)) {
tile.querySelector('[data-trigger="onBouncesList"]').closest('li[menuitem]').setAttribute('disabled','')
}
tile.querySelectorAll('[eicdropdown]').forEach(el => {
const dropDown = new DropDown(el)
dropDown.menu.el.querySelectorAll('[data-trigger]').forEach(el => {
el.addEventListener('click', this[el.dataset.trigger].bind(this, dropDown))
})
})
tile.querySelectorAll('[data-path]').forEach(el => el.addEventListener('click', this.onPathClicked.bind(this)))
tile.querySelectorAll('button[data-trigger]').forEach(el => {
const btn = new Button(el)
btn.click = this[el.dataset.trigger].bind(this, btn)
})
tile.querySelector('[data-trigger="onOneStatus"]').addEventListener('click', this.onOneStatus.bind(this))
//temporary until we have stats & unschedule screens
if(['sent'].includes(mailing.status)) ui.hide(tile.querySelector('[data-trigger="onMailingSelect"]'))
this.outputs.searchResults.append(tile)
if(mailing.status=='sent'){
new PieChart(tile.querySelector('[eicpiechart]'), {
title: '',
height: parseInt(getComputedStyle(document.querySelector('.mailings')).getPropertyValue('--chart-height'))+10,
data: [
{ severity: 'danger', value: mailing.kpis.bouncesCount },
{ severity: 'success', value: mailing.kpis.readCount },
{ severity: 'secondary', value: mailing.nbRecipients-mailing.kpis.bouncesCount-mailing.kpis.readCount }
]
})
} else {
tile.querySelector('[eicpiechart]').innerHTML = ''
}
}
this.updateStatusCounts(statusCounts)
}
onPathClicked(event){
event.stopPropagation()
event.preventDefault()
const criteria = this.components.searchCriteria.value.filter(item => (!item.startsWith('/'))) // Don't filter straight in the component, (dbl-trigger)
this.components.searchCriteria.value = [...criteria, event.target.dataset.path] // push not foreseen in component
}
async onPathBrowse() {
const button = this.find('[data-trigger="onPathBrowse"]')
this.showLoading(button)
let result = await this.mailings.getReadableFolders()
let { mailings, authorizedPaths } = result
if (!Array.isArray(authorizedPaths)) {
authorizedPaths = []
}
if (!authorizedPaths.includes('/mailing')) authorizedPaths.unshift('/mailing')
this.mailings.ffs.authorizedPaths = authorizedPaths
if (!mailings.mailings) {
mailings = { mailings }
}
this.mailings.ffs.loadStructure(mailings || {}, [])
this.mailings.ffs.currentPath = '/mailing'
this.mailings.ffs.authorizedPaths = authorizedPaths
let dialogResult = await this.openDialog(
await this.loadContent(
'templates/Ffs/dialogs/FileBrowserDialog',
{ title: `Select a path...` },
{
ffs: this.mailings.ffs,
mailings: this.mailings.mailings,
editable: false,
canManage: false,
startPath: '/mailing',
}
)
)
if (dialogResult) {
const criteria = this.components.searchCriteria.value.filter(item => (!item.startsWith('/')))
this.components.searchCriteria.value = [...criteria, dialogResult]
this.hideLoading(button)
}
this.hideLoading(button)
}
onMailingSelect(component, event){
event.stopPropagation()
event.preventDefault()
app.Router.route(`/mailings/${component.el.dataset.id}`)
}
async onRecipientsList(component, event){
const mid = event.target.dataset.id
component.button.loading = true
let recipients = await this.mailings.getRecipients(mid)
component.button.loading = false
this.toCSV( // Kick id & meta
recipients.map(row => { const { id, meta , ...filtered } = row; return(filtered) } ),
{ headers: ['Email', 'Source name', 'Source type' ],
filename: `Recipient`
}
)
}
async onBouncesList(component, event){
const mid = event.target.dataset.id
component.button.loading = true
const bounces = await this.mailings.getBounces(mid)
component.button.loading = false
this.toCSV( // Kick id & meta
bounces.map(row => { const { id, ...filtered } = row; return(filtered) } ),
{ headers: ['Email', 'Date bounced' ],
filename: `Bounces`
}
)
}
async onMailingCreate(component, event){
event.stopPropagation()
event.preventDefault()
component.loading = true
const path = this.authorizedPaths?.[0]
const response = await this.mailings.save({
status: "created",
path: path
})
component.loading = false
if(response && response.id) {
app.Router.route(`/mailings/${response.id}`)
}
}
}
app.registerClass('MailingDashboardView', MailingDashboardView)
@@ -0,0 +1,377 @@
<style>
.mailing-sheet > header { background: url('/app/assets/images/cards/mass-mailer.jpg'); }
.mailing-sheet .history { min-width: 320px; }
.mailing-sheet .sheet-content { display: none; }
.mailing-sheet .sheet-content[data-content="template"] section { display: grid; }
.mailing-sheet .sheet-content[data-content="template"] section .email-subject {
padding: var(--eicui-base-spacing-s);
border: 1px solid var(--eicui-base-color-grey-10);
margin: var(--eicui-base-spacing-xs) 0 var(--eicui-base-spacing-m) 0;
}
.mailing-sheet .sheet-content[data-content="template"] section .email-content {
border: 1px solid var(--eicui-base-color-grey-10);
border-top: 2px solid var(--eicui-base-color-grey-75);
padding: var(--eicui-base-spacing-m);
display: grid;
width: 1fr;
overflow: auto;
max-height: 70vh;
}
.mailing-sheet .sheet-content[data-content="approval"] .pane,
.mailing-sheet .sheet-content[data-content="schedule"] .form {
border: 1px solid var(--eicui-base-color-grey-10);
border-left: 3px solid var(--eicui-base-color-grey-50);
margin: var(--eicui-base-spacing-s) 0;
padding: 0 var(--eicui-base-spacing-m);
}
.mailing-sheet .sheet-content[data-content="schedule"] .form {
min-height: 20vh;
/*
align-content: center;
justify-content: center;
*/
}
.mailing-sheet .metrics { min-width: 160px; text-align: center; }
.mailing-sheet .recipients-grid .row { grid-template-columns: 150px 4fr 1fr 60px 100px; }
.mailing-sheet .recipients-grid .dataset .row .cell:nth-child(2),
.mailing-sheet .recipients-grid .dataset .row .cell:nth-child(4),
.mailing-sheet .recipients-grid .dataset .row .cell:nth-child(5) { text-align: center; }
.mailing-sheet .recipients-grid .cell.actions {
display: grid !important;
justify-content: right;
grid-auto-flow: column;
grid-gap: var(--eicui-base-spacing-xs);
}
.mailing-sheet .workflow { height: 20vh; }
.mailing-sheet .create-path-choice{
grid-template-columns: auto min-content;
align-items:center;
}
.mailing-sheet footer button{
justify-self: center;
}
.mailing-sheet span[info].icon-info{ margin-left:3rem; }
.mailing-sheet .history section{ max-height: 20rem; }
.mailing-sheet button[data-trigger="onDupsInfo"]{ width: min-content; }
.mailing-sheet div[data-output="rejectionReason1"],
.mailing-sheet div[data-output="rejectionReason2"],
.mailing-sheet div[data-output="reviewComments2"]{
background-color: var(--eicui-base-color-grey-20);
padding: 0.5rem;
}
.mailing-sheet [hidden] { display: none; }
</style>
<article eiccard media class="massmailer mailing-sheet">
<header>
<h1 class="mailing-name" data-output="name"></h1>
<h2>Communication Services</h2>
</header>
<section>
<div class="cols-2 left">
<div>
<article eiccard data-async="info">
<header>
<h1>General Information</h1>
</header>
<section>
<alert eicalert small muted info>current status is <span eicchip small info data-output="status"></span></alert>
<div class="cols-2">
<div>
<label xsmall>Created</label>
<span primary data-output="created"></span>
</div>
<div>
<label xsmall>Creator</label>
<span primary data-output="author"></span>
</div>
</div>
<label xsmall>Path</label>
<span primary data-output="infosPath"></span>
<article eiccard>
<section>
<div data-output="eicpiechart"></div>
<div class="cols-4">
<div class="center"><span xlarge><b data-output="recipients">0</b></span><label primary xsmall>recipients</label></div>
<div class="center"><span xlarge secondary><b data-output="pending">0</b></span><label secondary xsmall>pending</label></div>
<div class="center"><span xlarge danger><b data-output="bounced">0</b></span><label danger xsmall>bounced</label></div>
<div class="center"><span xlarge success><b data-output="reached">0</b></span><label success xsmall>opened</label></div>
</div>
</section>
</article>
</section>
<footer>
<div class="cols-1 right">
<button eicbutton danger small data-trigger="onMailingDelete" data-ref="deleteMailing">Delete this mailing</button>
</div>
</footer>
</article>
<article eiccard collapsable class="history" data-async="activity">
<header>
<h1>Latest activity</h1>
</header>
<section>
<ul nonbulleted data-output="history"></ul>
</section>
</article>
</div>
<div>
<div eicstatechart class="workflow" data-async="mainPane"></div>
<article eiccard class="sheet-content async" data-content="start" data-async="start">
<header>
<div class="cols-2 search-bar">
<h1>Initiate your Mailing
<span info class="icon-info" title="Enter the name of your mailing, then choose a place to store it, then hit Save to go to the next step."></span>
</h1>
</div>
</header>
<section>
<label>Name</label>
<input eicinput type="text" data-ref="mailingName" data-change="onNamePathChange"/>
<label>Path</label>
<dic class="cols-2 create-path-choice">
<div data-output="createPath" data-value=""></div>
<button eicbutton small primary data-trigger="onPathBrowse">Browse</button>
</dic>
</section>
<footer>
<button eicbutton medium primary data-trigger="onSaveCreated" data-ref="saveCreated" disabled>Save</button>
</footer>
</article>
<article eiccard class="sheet-content" data-content="template" data-async="template">
<header>
<div class="cols-2 right">
<div>
<h1>Content
<span info class="icon-info" title="Select the content of your mailing amongst the available templates, then proceed to Recipients."></span>
</h1>
<h2>Template name <b data-output="templateName"></b></h2>
</div>
<button eicbutton data-ref="changeTemplate" data-trigger="onTemplateChange">Select</button>
</div>
</header>
<section>
<div xlarge title="Email subject" class="email-subject"><b data-output="templateSubject"></b></div>
<menu eictab class="content-menu">
<li>HTML content</li>
<li>Text (alt.) content</li>
</menu>
<div class="email-content" data-output="templateHtmlBody"></div>
<div class="email-content text" data-output="templateAlternateText"></div>
<div class="cols-2" style="display: none;" >
<article eiccard>
<header>
<h1></h1>
<span class="template-path" eicchip="" secondary="" xsmall="">Current path:</span>
</header>
<section>
<div class="templates-grid"></div>
</section>
</article>
<article eiccard>
<header>
<h1>Template: </h1>
</header>
<section>
</section>
<footer>
<button eicbutton primary disabled data-trigger="onTestSend"><span>Send a test mail</span></button>
</footer>
</article>
</div>
</section>
</article>
<article eiccard class="sheet-content async" data-content="recipients" data-async="recipients">
<header>
<div class="cols-2 ">
<h1>Recipients
<span info class="icon-info" title="Select or import your recipients source(s). Once you have some recipients, you can proceed to the Mapping step."></span>
</h1>
</div>
</header>
<section>
<div class="cols-2 left">
<article eiccard class="metrics">
<header>
<h1>summary</h1>
</header>
<section>
<div>
<div>
<label xsmall><b>Total in sources</b></label>
<span xxxlarge><b data-output="totalRecipients">0</b></span>
</div>
<div>
<label xsmall><b data-output="labelDupes">Duplicates</b></label>
<span xxlarge><b data-output="totalRecipientsDupes">0</b></span>
<button eicbutton secondary rounded xsmall data-ref="dupsInfo" data-trigger="onDupsInfo" title="More info about those duplicates"><i class="icon-search"></i></button>
</div>
<div>
<label xsmall><b data-output="labelExcluded">Total excluded</b></label>
<span xxlarge><b data-output="totalRecipientsExcluded">0</b></span>
</div>
<div>
<label xsmall><b>Total sendable</b></label>
<span xxlarge><b data-output="totalFinalRecipients">0</b></span>
</div>
</div>
</section>
</article>
<article eiccard>
<header>
<div class="cols-2 right" style="grid-template-columns: auto max-content;">
<div>
<h1>Recipients sources</h1>
</div>
<div class="cols-3">
<button eicbutton primary small data-ref="btnFetch" data-trigger="onFetch" hidden><span>Add from MyEIC</span></button>
<button eicbutton primary small data-ref="btnImport" data-trigger="onImport" hidden><span>Add from Excel</span></button>
<button eicbutton primary small warning data-ref="btnExclusion" data-trigger="onExclusion" hidden><span>Add exclusion xls</span></button>
</div>
</div>
</header>
<section>
<div eicdatagrid footer="hidden" class="recipients-grid"></div>
</section>
</article>
</div>
</section>
</article>
<article eiccard class="sheet-content async" data-content="mappings" data-async="mappings">
<header>
<h1>Data mapping
<span info class="icon-info" title="When all variables are mapped for all your recipients sources, you'll be able test and review (or let review) your mailing."></span>
</h1>
</header>
<section data-output="mappingsPanel"></section>
</article>
<article eiccard class="sheet-content async" data-content="approval" data-async="review">
<header>
<h1>Approval</h1>
<h2>Last checks before scheduling your mailing for expedition</h2>
</header>
<section class="approval-panel">
<div class="pane" data-output="testPane">
<label xlarge >Test your content</label>
<div class="cols-2 right middle">
<p>Send a copy of the email content to your mailbox</p>
<button eicbutton primary data-trigger="onTestSend"><span>Send test email...</span></button>
</div>
</div>
<div class="pane" data-output="revieweePane">
<label xlarge >Review</label>
<div data-output="revieweeOngoing">
<p>This mailing is currently being reviewed.</p>
</div>
<div data-output="revieweeApproved">
<div eicalert success>Your mailing has been approved by <b data-output="approUser1"></b> on <b data-output="approDate1"></b></div>
</div>
<div data-output="revieweeRejected">
<div eicalert danger>
<p>Your mailing has been rejected by <b data-output="rejectionUser1"></b> on <b data-output="rejectionDate1"></b></p>
<label medium>Reason</label>
<div small data-output="rejectionReason1"></div>
<p xsmall>Read the above comments, adapt your mailing accordingly and request another review.</p>
<div class="cols-1 center">
<button eicbutton primary data-trigger="onReviewRequest"><span>Request new review</span></button>
</div>
</div>
</div>
<div class="cols-2 right" data-output="revieweeRequest">
<p>In order to schedule the expedition of your mailing, it must be reviewed and approved first.</p>
<button eicbutton primary data-trigger="onReviewRequest"><span>Request review</span></button>
</div>
</div>
<div class="pane" data-output="reviewerPane">
<label xlarge >Review request</label>
<div data-output="reviewerChoice">
<div eicalert info>
<div>Final review requested by <b data-output="requestUser2"></b> on <b data-output="requestDate2"></b></div>
<label medium>Remarks / Comments</label>
<div small data-output="reviewComments2"></div>
</div>
<p xsmall>Please, have a thorough check on email content and recipients before giving the final GO</p>
<div class="cols-2 buttons">
<button eicbutton warning data-trigger="onReviewReject"><span>Request changes (Reject sending)</span></button>
<button eicbutton primary data-trigger="onReviewApprove"><span>Approve sending</span></button>
</div>
</div>
<div data-output="reviewerApproved">
<div eicalert success >Mailing approved by <b data-output="approUser2"></b> on <b data-output="approDate2"></b> </div>
</div>
<div data-output="reviewerRejected">
<div eicalert warning >
<div>Mailing rejected by <b data-output="rejectionUser2"></b> on <b data-output="rejectionDate2"></b></div>
<label medium>Reason:</label>
<div small data-output="rejectionReason2"></div>
</div>
</div>
</div>
</section>
</article>
<article eiccard class="sheet-content async" data-content="schedule" data-async="schedule">
<header>
<h1>Expedition</h1>
<h2></h2>
</header>
<section class="schedule-panel">
<div data-output="toSchedule">
<div eicalert info>
<span>
You are ready to send your mails. Please, choose when you would like to process.<br/>
You may choose to sent it now (mails will be processed within 5 minutes), or to schedule it for a specific date and time.
</span>
</div>
<div class="cols-2 center middle">
<div class="immediate form">
<label large>Send the mailing now</label>
<p>(mails will be processed within 5 minutes)</p>
<input eicinput type="hidden" data-ref="scheduleDateNow" value="${new Date().toISOString().split('T')[0]}" />
<input eicinput type="hidden" data-ref="scheduleTimeNow" value="5min" />
<button eicbutton primary data-ref="sendButton" data-trigger="onSendingNow"><span>Send now</span></button>
</div>
<div class="scheduled form">
<label large>Schedule sending for later</label>
<div class="cols-2">
<div>
<label small>Date:</label>
<input eicinput type="date" data-ref="scheduleDateLater" min="${new Date().toISOString().split('T')[0]}" value="${new Date().toISOString().split('T')[0]}" data-change="onScheduleDateChange" >
</div>
<div>
<label small>Time:</label>
<select eicselect name="scheduleTime" data-ref="scheduleTimeLater" data-change="onScheduleTimeChange">
<option></option>
</select>
</div>
</div>
<button class="schedule" eicbutton disabled primary data-ref="scheduleButton" data-trigger="onSendingLater"><span>Schedule</span></button>
</div>
</div>
<div class="cols-1 center">
<button class="schedule" eicbutton secondary data-trigger="onAbortSending"><span>Abort sending</span></button>
</div>
</div>
<div data-output="scheduled">
<div eicalert info>
<span>
This mailings has been scheduled for <b data-output="scheduledDate"></b>.<br/>
As soon as it is sent, you'll be able to access it's statistics from the mailing dashboard.
</span>
</div>
<div class="cols-1 right" data-output="unschedule">
<button class="right" eicbutton="" warning="" data-trigger="onUnschedule">Unschedule</button>
</div>
</div>
</section>
</article>
</div>
</section>
</article>
@@ -0,0 +1,872 @@
class MailingSheetView extends EICDomContent {
constructor() {
super()
Object.assign(this, app.helpers.activeAttributes)
Object.assign(this, app.helpers.basicDialogs)
Object.assign(this, app.helpers.validators)
}
DOMContentLoaded(options) {
for(let model in options.models) this[model] = options.models[model]
const components = ui.eicfy(this.el)
this.setupTriggers(components)
this.setupRefs(components)
this.currentMailing = {}
this.mailingId = options.id
this.workflow = new NodeMap(this.find('.workflow'), {
resizable: true,
allowDrag: false,
orientation: 'linear',
entity: { width: 120, height: 40, gap: 40 }
})
this.workflow.click = this.onWorkflowSelect.bind(this)
this.recipientsGrid = new DataGrid(this.find('.recipients-grid'), {
headers: [
{label: 'date', type: 'date'},
{label: 'source'},
{label: 'type', type: 'markup'},
{label: '<span small class="icon-user" title="recipients"></span>'}
],
actions:[],
})
this.recipientsGrid.enableFooter = true
this.recipientsGrid.updateFooter = ()=>{} //We'll manage our own footer thx
this.templateShadowRoot = this.outputs.templateHtmlBody.attachShadow({ mode: 'open' })
this.components['tabs'] = new Tab()
this.components['tabs'].addTabs(this.findAll('.content-menu li'), this.findAll('.email-content'))
ui.hide(this.components.deleteMailing.el)
this.fillScheduleTime()
this.loadMailingInfo(this.mailingId )
}
/****************************** Loading & refresh tabs **************************************/
async loadMailingInfo(mailingId) {
this.setAsyncLoading(true)
this.currentMailing = await this.mailings.get(mailingId)
const rawPaths = await this.mailings.getReadableFolders()
const authorizedPaths = Array.isArray(rawPaths) ? rawPaths : (rawPaths?.authorizedPaths || [])
if (!authorizedPaths.includes(this.currentMailing.path)) {
const wrapper = document.querySelector('.massmailer.mailing-sheet section')
if (wrapper) {
wrapper.innerHTML = `
<div style="padding: 2rem; text-align: center;">
<h2 style="color:#b00020;">Access Denied</h2>
<p>You're not authorized to access to view this mailing</p>
</div>`
}
this.setAsyncLoading(false)
return
}
this.refresh()
this.setAsyncLoading(false)
}
async refresh() {
this.workflow.data = app.meta.collections['wf-mailings'] //Clear
if(this.currentMailing.status == 'sent'){
this.components.chart = new PieChart(this.outputs.eicpiechart, { height: 120 })
this.components.chart.data = [
{ severity: 'danger', value: this.currentMailing.kpis.bouncesCount },
{ severity: 'success', value: this.currentMailing.kpis.readCount },
{
severity: 'secondary',
value: this.currentMailing.nbRecipients - this.currentMailing.kpis.bouncesCount - this.currentMailing.kpis.readCount
}
]
//TODO Show stats & graph tab
} else {
this.fillGeneralInfo()
this.fillLatestActivity()
this.refreshCreated()
await this.refreshContent()
this.fillRecipients()
this.refreshMappings()
this.refreshReview()
this.refreshSchedule()
this.refreshWorflow()
}
//this.setupTriggers()
}
fillGeneralInfo(){
this.output('name', this.currentMailing.name)
this.output('created', ui.format.date(this.currentMailing.statusHistory[0].dateTime))
this.output('author', this.currentMailing.statusHistory[0].changedBy.euLoginId)
this.output('status', this.mailings.getStatusLabel()[this.currentMailing.status])
this.output('infosPath', this.pathChips(this.currentMailing.path))
this.output('recipients', this.currentMailing.nbRecipients)
this.output('pending', (this.currentMailing.nbRecipients>0) ? this.currentMailing.nbRecipients - this.currentMailing.kpis.bouncesCount - this.currentMailing.kpis.readCount : 'N/A')
this.output('bounced', this.currentMailing.kpis.bouncesCount || 'N/A')
this.output('reached', this.currentMailing.kpis.readCount || 'N/A')
if(['created','draft'].includes(this.currentMailing.status)){
ui.show(this.components.deleteMailing.el)
} else {
ui.hide(this.components.deleteMailing.el)
}
}
fillLatestActivity(){
this.output('history', '')
let prevstatus = ''
for(let historyEntry of [...this.currentMailing.statusHistory].reverse()){
let action
if((historyEntry.value=='draft') && (prevstatus!= 'rejected')) { action = 'Draft saved' }
else action = this.mailings.getStatusLabel()[historyEntry.value]
let severity
switch(historyEntry.value) {
case 'rejected': severity = 'danger'; break
case 'approved': severity = 'success'; break
case 'submitted': severity = 'warning'; break
case 'sent': severity = 'primary'; break
default : severity = 'info'; break
}
let markup = `<li>
<div eicalert muted ${severity} xsmall>
${action}: <b>${ (new Intl.DateTimeFormat("fr-FR", {timeZone: "Europe/Paris", dateStyle:'medium', timeStyle:'medium'})).format(new Date(historyEntry.dateTime))}</b><br>
by <b>${historyEntry.changedBy.firstname} ${historyEntry.changedBy.lastname}</b>
</div></li>`
prevstatus = historyEntry.value
this.outputs.history.append(ui.create(markup))
}
}
refreshCreated(){
this.components.saveCreated.label = (this.currentMailing.status=='created') ? 'Save' : 'Change'
this.components.saveCreated.severity = (this.currentMailing.status=='created') ? 'primary' : 'secondary'
this.components.mailingName.value = this.currentMailing.name
this.outputs.createPath.dataset.value = this.currentMailing.path
this.outputs.createPath.innerHTML = this.pathChips(this.currentMailing.path)
this.onNamePathChange()
}
async refreshContent(){
if(this.currentMailing.template.id) {
this.setAsyncLoading(true, ['mainPane'])
let templateInfo = await this.templates.get(this.currentMailing.template.id)
this.setAsyncLoading(false, ['mainPane'])
if(templateInfo) {
this.currentMailing.template = templateInfo
this.fillTemplate(templateInfo);
}
}
this.components.changeTemplate.severity = (this.currentMailing.template.id) ? 'secondary' : 'primary'
}
refreshWorflow() {
const stepActivations = this.mailings.getStepActivations(this.currentMailing, this.templates.getTokens(this.currentMailing.template))
this.workflow.data = app.meta.collections['wf-mailings']
for(const entity of this.workflow.entities){
entity.disabled = stepActivations[entity.id].disabled
entity.severity = stepActivations[entity.id].severity
}
let entity = null
if(this.currentMailing.status!='scheduled'){ entity = this.workflow.entities.find(e => e.severity == 'warning') }
else { entity = this.workflow.entities.find(e => e.id == 'schedule') }
this.onWorkflowSelect(entity)
}
fillTemplate(templateInfo){
const filterText = (text) => {
let element = document.createElement('div')
element.textContent = text
text = element.innerHTML
text = text.length > 100 ? text.slice(0, 100) + '...' : text
return text
}
let subject = filterText(templateInfo.meta.subject ? templateInfo.meta.subject : '')
this.output('templateSubject', subject.replace(this.validators.mustacheTokenRegex,'<span class="token" eicchip success>$1</span>'))
let html = atob(templateInfo.html).replace(this.validators.mustacheTokenRegex,'<span class="token" eicchip success>$1</span>')
this.templateShadowRoot.innerHTML = `<style>
span.token[eicchip]{
border-radius: 60px !important;
background-color: var(--eicui-base-color-success-100);
color: var(--eicui-base-color-white);
font-size: 0.75rem;
padding: var(--eicui-base-spacing-2xs) var(--eicui-base-spacing-s);
min-height: var(--eicui-base-spacing-xl);
display: inline-grid;
grid-template-columns: min-content min-content min-content;
min-height: 0;
align-items: center;
margin: 1px var(--eicui-base-spacing-2xs);
pointer-events: all;
white-space: nowrap;
position: relative;
text-shadow: none;
}
</style>\n${html}`
let altText = filterText(templateInfo.meta.altText ? atob(templateInfo.meta.altText) : '')
this.output('templateAlternateText', `<pre>${altText.replace(this.validators.mustacheTokenRegex,'<span class="token" eicchip success>$1</span>')}</pre>`)
this.output('templateName', templateInfo.name.length > 60 ? templateInfo.name.slice(0, 60) + '...' : templateInfo.name)
this.outputs['templateName'].setAttribute('title', templateInfo.name)
}
fillRecipients() {
this.recipientsGrid.clear()
let totalUnfilteredRecipients = 0
if(this.mailings.canImport()) { ui.show(this.components.btnImport.el, 'inline-flex') }
else { ui.hide(this.components.btnImport.el) }
if(this.mailings.canImportExclusion){ ui.show(this.components.btnExclusion.el, 'inline-flex') }
else{ui.hide(this.components.btnExclusion.el) }
if(this.mailings.canFetch()) { ui.show(this.components.btnFetch.el, 'inline-flex') }
else { ui.hide(this.components.btnFetch.el) }
for(const emailSource of this.currentMailing.sources){
const oneImport = this.currentMailing.imports.find(i => i.sourceId==emailSource.id)
if(!emailSource.negative) totalUnfilteredRecipients += oneImport.data.length
let sourceType = (emailSource.sourceType=='Excel') ? `<span class="icon-xls" title="Imported Excel" ${emailSource.negative ? 'warning':''}></span>` : `<span class="icon-market" title="Database query (${emailSource.sourceType})"></span>`
if(emailSource.negative) sourceType = `<span eicbadge="" warning="" xsmall="" class="icon-deny" title="Exclusion list"></span>${sourceType}`
let row = this.recipientsGrid.addRow(emailSource.id, [
oneImport.importDate, // expecting source import datestamp
emailSource.sourceName,
sourceType,
oneImport.data.length
], true)
if(emailSource.sourceType!='Excel'){
// let btnEditQC = new Button(null,{ icon: 'icon-edit', severity: 'primary', size: 'xsmall', hint: 'Change the query'})
// btnEditQC.click = this.onSourceEditQC.bind(this, oneImport.id )
// row.querySelector('.cell.actions').appendChild(btnEditQC.el)
let btnRefreshQC = new Button(null,{ icon: 'icon-refresh', severity: 'primary', size: 'xsmall', hint: 'Refresh data by replaying the query now'})
btnRefreshQC.click = this.onSourceRefreshQC.bind(this, oneImport.id, btnRefreshQC )
row.querySelector('.cell.actions').appendChild(btnRefreshQC.el)
}
let btnDel = new Button(null,{ icon: 'icon-bin', severity: 'danger', size: 'xsmall', hint: 'Remove'})
btnDel.click = this.onSourceDel.bind(this, oneImport.id )
row.querySelector('.cell.actions').appendChild(btnDel.el)
}
this.recipientsGrid.filter() // because skipfilter = true because potentially Big Xls...
this.recipientsGrid.footer.classList.add('row')
this.recipientsGrid.footer.innerHTML = `
<div class="cell"></div>
<div class="cell"></div>
<div class="cell" title="Total recipients"><i>Total sendable recipients</i></div>
<div class="cell" title=""></div>
<div class="cell" title="${this.currentMailing.nbRecipients}"><i>${this.currentMailing.nbRecipients}</i></div>
<div class="cell actions"></div>
`
//this.currentMailing.nbRecipients = totalRecipients
const dupes = this.mailings.findDupes(this.currentMailing.imports)
this.output('totalRecipients', totalUnfilteredRecipients)
this.output('totalRecipientsExcluded', totalUnfilteredRecipients-this.currentMailing.nbRecipients)
if(totalUnfilteredRecipients-this.currentMailing.nbRecipients>0) {
this.outputs.totalRecipientsExcluded.setAttribute('warning','')
this.outputs.labelExcluded.setAttribute('warning','')
} else {
this.outputs.totalRecipientsExcluded.removeAttribute('warning')
this.outputs.labelExcluded.removeAttribute('warning')
}
this.output('totalFinalRecipients', this.currentMailing.nbRecipients)
this.output('totalRecipientsDupes', Object.keys(dupes).length)
if(Object.keys(dupes).length>0){
this.outputs.totalRecipients.parent
this.outputs.totalRecipientsDupes.setAttribute('danger','')
this.outputs.labelDupes.setAttribute('danger','')
this.components.dupsInfo.dupes = dupes //hell yeah
ui.show(this.components.dupsInfo.el, 'inline-flex')
} else {
this.outputs.totalRecipientsDupes.removeAttribute('danger')
this.outputs.labelDupes.removeAttribute('danger')
this.components.dupsInfo.dupes = null
ui.hide(this.components.dupsInfo.el)
}
}
refreshMappings() {
if((!this.currentMailing.template) || (!this.currentMailing.template.id)) return
const templateTokens = this.templates.getTokens(this.currentMailing.template)
this.output('mappingsPanel', '')
const nbMapMissingPerSrc = this.mailings.nbMapMissingPerSrc(this.currentMailing, templateTokens)
for(let source of this.currentMailing.sources){
if(source.negative) continue
const sourceType = (source.sourceType=='Excel') ? `<span class="icon-table" title="Imported Excel"></span>` : `<span class="icon-market" title="Database query (${source.sourceType})"></span>`
let article = ui.create(`<div>
<article eiccard collapsable ${nbMapMissingPerSrc[source.id] > 0 ? '': 'collapsed' }>
<header>
<h1>${sourceType} ${source.sourceName}</h1>
<h2>${nbMapMissingPerSrc[source.id] > 0 ? `<span eicchip xsmall warning>${nbMapMissingPerSrc[source.id]} missing</span>`: '<span eicchip small success>complete</span>' } </h2>
</header>
<section>
<ul nonbulleted>
<li class="cols-2">
<label large>Content variable</label>
<label large>Recipient data</label>
</li>
</ul>
</section>
</article></div>
`)
let oneMapping = this.currentMailing.mappings.find(i => i.sourceId==source.id)
if(!oneMapping){
oneMapping = {
"sourceName": source.sourceName,
"sourceType": source.sourceType,
"mappings": { },
"sourceId": source.id,
}
this.currentMailing.mappings.push(oneMapping)
}
let ul = article.querySelector('section ul')
for(let variable of templateTokens) {
let severity, label, title
if(variable in oneMapping.mappings) {
severity = 'secondary'
label = oneMapping.mappings[variable].hint
title = oneMapping.mappings[variable].hint
} else {
severity = 'primary'
label = 'Select...'
title = ''
}
let li = ui.create(`<li class="cols-2 middle">
<div small><b>${variable}</b></div>
<div>
<button eicbutton small ${severity} rounded data-token="${variable}" data-sourceid="${source.id}" data-trigger="onMappingChange" title="${title}">
<span>${label}</span>
</button>
</div>
</li>`)
ul.appendChild(li)
}
let components = ui.eicfy(article)
this.setupTriggers(components)
this.outputs.mappingsPanel.append(article)
}
}
refreshReview(){
const [block2display, contents] = this.mailings.getReviewBlocks(this.currentMailing)
const allBlocks = ['revieweePane','reviewerPane',
'revieweeOngoing','revieweeApproved','revieweeRejected','revieweeRequest',
'reviewerChoice','reviewerApproved','reviewerRejected']
allBlocks.forEach(blockName => ui.hide(this.outputs[blockName]))
block2display.forEach(blockName => ui.show(this.outputs[blockName], 'grid'))
for(let blockName in contents) this.output(blockName, contents[blockName])
}
refreshSchedule(){
if(this.currentMailing.status=='scheduled'){
ui.hide(this.outputs.toSchedule)
ui.show(this.outputs.scheduled)
const scheduledStatus = this.mailings.getLatestStatus(this.currentMailing,'scheduled')
const scheduleDate = (new Intl.DateTimeFormat("en-GB", {timeZone: "Europe/Paris", dateStyle:'medium', timeStyle:'medium'}))
.format(new Date(scheduledStatus.meta.scheduleDate))
this.output('scheduledDate', scheduleDate)
if(app.User.roles.includes('MAIL_Sender')) ui.show(this.outputs.unschedule, 'grid')
else ui.hide(this.outputs.unschedule)
} else {
ui.hide(this.outputs.scheduled)
ui.show(this.outputs.toSchedule)
}
}
/**************************************** Event handlers ******************************************/
async onPathBrowse() {
const button = this.find('button[data-trigger="onPathBrowse"]')
this.showLoading(button)
try {
let result = await this.mailings.getReadableFolders()
if (!result || typeof result !== 'object') {
console.error("getReadableFolders returned invalid result", result)
return
}
let { mailings, authorizedPaths, foldersWithFiles, rights } = result
if (!Array.isArray(authorizedPaths)) authorizedPaths = []
if (!authorizedPaths.includes('/mailing')) authorizedPaths.unshift('/mailing')
this.mailings.ffs.authorizedPaths = authorizedPaths;
this.mailings.ffs.foldersWithFiles = foldersWithFiles || []
if (!mailings?.mailings) {
mailings = { mailings }
}
const fileList = Array.isArray(mailings.mailings) ? mailings.mailings : []
this.mailings.ffs.loadStructure({ mailing: mailings.mailings || mailings }, fileList)
this.mailings.ffs.currentPath = '/mailing'
this.mailings.ffs.authorizedPaths = authorizedPaths
this.mailings.ffs.rights = rights || []
this.createPath = this.find('div[data-output="createPath"]')
this.spanPath = this.createPath ? this.createPath.querySelector('span[data-path]') : null
this.currentPath = this.spanPath ? this.spanPath.getAttribute('data-path') : '/mailing'
let dialogResult = await this.openDialog(
await this.loadContent(
'templates/Ffs/dialogs/FileBrowserDialog',
{ title: `Select a path...` },
{
ffs: this.mailings.ffs,
editable: false,
startPath: this.currentPath,
canManage: true,
mailings: this.mailings,
context: 'mailings'
}
)
)
if (dialogResult) {
this.currentMailing.path = dialogResult
this.output('createPath', this.pathChips(this.currentMailing.path))
this.outputs.createPath.dataset.value = dialogResult
this.onNamePathChange()
}
} catch (error) {
console.error("Error on dialog PathSearch loading :", error)
} finally {
this.hideLoading(button);
}
}
onNamePathChange(){
this.components.saveCreated.disabled = ((this.components.mailingName.value=='') || (this.outputs.createPath.dataset.value ==''))
}
async onSaveCreated(component, event){
this.currentMailing.name = this.components.mailingName.value
this.currentMailing.path = this.outputs.createPath.dataset.value
this.currentMailing.status = 'draft'
this.components.saveCreated.loading = true
try {
await this.mailings.save(this.currentMailing)
ui.growl.append('Mailing saved !', 'success',3000)
} catch(e) {}
this.components.saveCreated.loading = false
this.loadMailingInfo(this.currentMailing.id)
}
onWorkflowSelect(entity) {
if(!entity) return
this.findAll('.sheet-content').forEach(el => {ui.hide(el)})
let target = entity.el.dataset.id
let tab = this.find(`[data-content="${target}"]`)
const stepActivations = this.mailings.getStepActivations(this.currentMailing, this.templates.getTokens(this.currentMailing.template))
if(stepActivations[target].disabled) return // Maybe this is the logical next step, brought automatically, but your role doesn't allow for it.
if(tab) {
ui.show(tab)
switch(target){
case 'template':
// todo display content corresponding to status/role
break
case 'recipients':
// todo display content corresponding to status/role
break
case 'mappings':
// todo display content corresponding to status/role
break
case 'approval':
// todo display content corresponding to status/role
break
case 'schedule':
// todo display content corresponding to status/role
//if(!this.mailings.hasPrivilege('schedule')) return
break
default:
}
} else {
ui.growl.append('Content unavailable', 'danger')
}
}
async onTemplateChange() {
const templateTokens = this.templates.getTokens(this.currentMailing.template)
if(templateTokens && (templateTokens.length>0) && (this.currentMailing.mappings.length>0)){
let result = await this.confirmDialog({
title: `CAUTION: Existing mappings...`,
message: `
<p>You have existing mappings for the current Template.<br>
If you do select a new template (after previewing it),<br>
<b>these mappings will be lost !</b></p>`,
okLabel: 'I understand',
severity: 'warning',
})
if(!result) return
}
let result = await this.openDialog(
await this.loadContent(
'comms/mailings/sheet/dialogs/MailingTemplatePreviewDialog',
{ title: `Pick your content template` },
{ status: ['prod'],
currentMailing: this.currentMailing,
templateModel: this.templates,
mailingModel: this.mailings,
}
)
)
if(result) this.loadMailingInfo(this.currentMailing.id)
}
async onSourceDel(importId, event) {
event.preventDefault()
event.stopPropagation()
const oneImport = this.currentMailing.imports.find(i => i.id==importId)
let result = await this.confirmDialog({
title: (!oneImport.negative) ? `Remove these ${oneImport.data.length} recipients ?` : `Remove this exclusion list and re-allow ${oneImport.data.length} emails ?`,
message: `<p>Are you sure ?</p>`,
okLabel: 'Delete',
severity: 'danger',
okPromise: data => this.mailings.deleteImport(this.currentMailing.id, importId)
})
if(result) this.loadMailingInfo(this.currentMailing.id)
}
async onSourceRefreshQC(importId, btn, event) {
event.preventDefault()
event.stopPropagation()
btn.loading = true
const curImport = this.currentMailing.imports.find(i => i.id==importId)
const curSource = this.currentMailing.sources.find(i => i.mli_id==importId)
const tmpResults = await this.contactMgr.getSample(curSource.query_id, curSource.query_params)
console.log('=====>tmpResults', tmpResults)
let result = await this.confirmDialog({
title: `
`,
message: `<p>You are about to re-run the query !<br>
Currenly you have <b>${curImport.data.length} recipients</b>.<br>
After re-running the query, you would have <b>${tmpResults.nbRows} recipients</b>.</p>
<p>Update the data?</p>`,
okLabel: 'Yes, rerun the query !',
severity: 'warning',
okPromise: data => this.mailings.refreshImport(this.currentMailing.id, {
sourceType: curImport.sourceType,
refreshOnly: true,
importId: curImport.id,
}, importId).then(
() => btn.loading = false,
() => btn.loading = false
)
})
if(result) this.loadMailingInfo(this.currentMailing.id)
btn.loading = false
}
// async onSourceEditQC(importId, event) {
// event.preventDefault()
// event.stopPropagation()
// const curImport = this.currentMailing.imports.find(i => i.id==importId)
// console.log('=====>onSourceEditQC:', curImport.data.length)
// }
async onImport(){
let result = await this.openDialog(
await this.loadContent(
'comms/mailings/sheet/dialogs/MailingImportDialog',
{ title: `Import recipients from Excel file...` },
{ model: this.mailings,
mid: this.currentMailing.id,
}
)
)
if(result) this.loadMailingInfo(this.currentMailing.id)
}
async onExclusion(){
let result = await this.openDialog(
await this.loadContent(
'comms/mailings/sheet/dialogs/MailingExclusionDialog',
{ title: `Import exclusion list from Excel file...` },
{ model: this.mailings,
mid: this.currentMailing.id,
}
)
)
if(result) this.loadMailingInfo(this.currentMailing.id)
}
async onFetch(){
let result = await this.openDialog(
await this.loadContent(
'comms/mailings/sheet/dialogs/MailingFetchDialog',
{ title: `Fetch recipients from MyEIC applications...` },
{
models: {
contacts: this.contactMgr,
mailings: this.mailings,
},
mid: this.currentMailing.id,
}
)
)
if(result) this.loadMailingInfo(this.currentMailing.id)
}
async onDupsInfo(component, event) {
let result = await this.openDialog(
await this.loadContent(
'comms/mailings/sheet/dialogs/MailingDuplicatesDialog',
{ title: `Duplicate emails...` },
{ dupes: component.dupes,
}
)
)
}
async onMappingChange(component, event) {
let result = await this.openDialog(
await this.loadContent(
'comms/mailings/sheet/dialogs/MailingMappingDialog',
{ title: `Link template variable to a column in the Excel file...` },
{
model: this.mailings,
token: component._el.dataset.token,
sourceId: component._el.dataset.sourceid,
currentMailing: this.currentMailing,
}
)
)
if(result) this.loadMailingInfo(this.currentMailing.id)
}
async onTestSend(){
let result = await this.openDialog(
await this.loadContent(
'comms/mailings/sheet/dialogs/MailingTestDialog',
{ title: `Sending a test mail...` },
{ tokens: this.templates.getTokens(this.currentMailing.template) }
)
)
if(result){
await this.mailings.test(this.currentMailing.id, this.currentMailing.template.id, result.email, result.tokenValues)
}
}
async onReviewRequest(){
let result = await this.confirmDialog({
title: 'Request review ?',
message: `You're about to request for this mailing to be reviewed.<br>
During the review process, the mailing won't be editable anymore.
<div class="prompts">
<label small>Remarks / comments for the reviewer:</label>
<textarea name="comments" eictextarea maxlen="500" cols="50"/></textarea>
</div>
`,
promptsClass: 'prompts',
okLabel: 'Request review',
severity: 'warning',
okSeverity: 'primary',
okPromise: (data) => {
this.currentMailing.status = 'submitted'
this.currentMailing.statusMeta = { comments: data.comments }
return(this.mailings.save(this.currentMailing))
}
})
if(result) { this.loadMailingInfo(this.currentMailing.id) }
}
async onReviewApprove() {
let result = await this.confirmDialog({
title: 'Approve mailing ?',
message: `Approve this mailing for sending ?`,
okLabel: 'Approve',
severity: 'success',
okSeverity: 'primary',
okPromise: (data) => {
this.currentMailing.status = 'approved'
return(this.mailings.save(this.currentMailing))
}
})
if(result) { this.loadMailingInfo(this.currentMailing.id) }
}
async onReviewReject(){
let result = await this.confirmDialog({
title: 'Reject mailing',
message: `Reject sending of this mailing ?
<div class="prompts">
<label small>Changes requested / Reason for rejection:</label>
<textarea name="reason" eictextarea maxlen="500" cols="50"/></textarea>
</div>
`,
promptsClass: 'prompts',
okLabel: 'Reject sending',
severity: 'warning',
okSeverity: 'primary',
okPromise: (data) => {
this.currentMailing.status = 'rejected'
this.currentMailing.statusMeta = { reason: data.reason }
return(this.mailings.save(this.currentMailing))
}
})
if(result) { this.loadMailingInfo(this.currentMailing.id) }
}
onScheduleDateChange(){
this.components.scheduleTimeLater.value = ''
this.fillScheduleTime()
this.components.scheduleButton.disabled = true
}
onScheduleTimeChange() { this.components.scheduleButton.disabled = (this.components.scheduleTimeLater.value == '') }
fillScheduleTime(){
const today = new Date().toISOString().split('T')[0]
const thisHour = (new Date()).getHours()
this.components.scheduleTimeLater.el.innerHTML=''
if(this.components.scheduleDateLater.value==today){
if((thisHour+1) < 24) {
for(let h=thisHour+1; h<24; h++) this.components.scheduleTimeLater.el.append(ui.create(`<option value="${h}">${h.toString().padStart(2, '0')}:00 (${h < 12 ? 'AM' : 'PM'})</option>`))
}
} else {
for(let h=0; h<24; h++) this.components.scheduleTimeLater.el.append(ui.create(`<option value="${h}">${h.toString().padStart(2, '0')}:00 (${h < 12 ? 'AM' : 'PM'})</option>`))
}
}
async onSendingNow(component, event) { await this.sendMailing(this.components.scheduleDateNow.value, this.components.scheduleTimeNow.value) }
async onSendingLater(component, event) { await this.sendMailing(this.components.scheduleDateLater.value, this.components.scheduleTimeLater.value) }
async sendMailing(scheduleDate, scheduleTime) {
const getCETOffset = () => {
const date = new Date()
const options = { timeZone: "Europe/Paris", timeZoneName: "short" }
const formatter = new Intl.DateTimeFormat("en-US", options)
const parts = formatter.formatToParts(date)
const timeZoneOffset = parts.find(part => part.type === "timeZoneName").value
return timeZoneOffset.includes("CEST") ? 2 : 1
}
const today = new Date().toISOString().split('T')[0]
const [year, month, day] = scheduleDate.split("-")
const europeanDate = `${day}/${month}/${year}`
let when
if(scheduleDate==today){
when = scheduleTime=='5min' ? 'in five minutes' : `today at ${scheduleTime.toString().padStart(2, '0')}:00 (${scheduleTime < 12 ? 'AM' : 'PM'})`
} else {
when = `on ${europeanDate} at ${scheduleTime.toString().padStart(2, '0')}:00 (${scheduleTime < 12 ? 'AM' : 'PM'})`
}
let UTCScheduleTime
if(scheduleTime!='5min'){
UTCScheduleTime = (new Date(`${scheduleDate}T${scheduleTime.toString().padStart(2, '0')}:00:00+0${getCETOffset()}:00`)).toISOString()
} else {
UTCScheduleTime = (new Date((new Date).getTime() + 5 * 60 * 1000)).toISOString()
}
let result = await this.confirmDialog({
title: 'Send mailing ?',
message: `You are about to schedule the sending of this mailing ${when}`,
okLabel: 'Schedule sending',
severity: 'secondary',
okSeverity: 'danger',
okPromise: (data) => {
return(this.mailings.schedule(this.currentMailing.id, UTCScheduleTime))
}
})
if(result) { this.loadMailingInfo(this.currentMailing.id); }
}
async onAbortSending(component, event) {
let result = await this.confirmDialog({
title: 'Abort the sending ?',
message: `You are about to <b>abort the sending of this mailing !</b><br>
If you proceed, it will be editable again, <br>
and it will have to be reviewed again.<br>
Is this really what you want to do ?`,
okLabel: 'Abort',
severity: 'warning',
cancelSeverity: 'primary',
okSeverity: 'secondary',
okPromise: (data) => {
this.currentMailing.status = 'draft'
return(this.mailings.save(this.currentMailing))
}
})
if(result) { this.loadMailingInfo(this.currentMailing.id) }
}
async onUnschedule(component, event) {
let result = await this.confirmDialog({
title: 'Unschedule the sending ?',
message: `You are about to <b>unschedule this mailing !</b><br>
If you proceed, it will not be sent, <br>
and you will be able to re-schedule or to abort it.<br>
Is this really what you want to do ?`,
okLabel: 'Unschedule',
severity: 'warning',
cancelSeverity: 'primary',
okSeverity: 'secondary',
okPromise: (data) => {
this.currentMailing.status = 'approved'
return(this.mailings.save(this.currentMailing))
}
})
if(result) { this.loadMailingInfo(this.currentMailing.id) }
}
async onMailingDelete(component, event) {
let result = await this.confirmDialog({
title: 'DELETE this mailing ?',
message: `You are about to <b>DELETE this mailing !</b><br>
If you proceed, it will not be sent, <br>
and everything it contains will be lost.<br>
Is this really what you want to do ?`,
okLabel: 'Delete',
severity: 'danger',
cancelSeverity: 'primary',
okSeverity: 'secondary',
okPromise: (data) => {
return(this.mailings.delete(this.currentMailing.id))
}
})
if(result) { app.Router.route(`/mailings`) }
}
pathChips(path){
const dirs = path.split('/').filter(item=>item)
const markup = (dirs.length>0) ? dirs.map((dir, idx) => {
const path = dirs.slice(0, idx+1).join('/')
return(`<span eicchip xxsmall info data-path="/${path}">${dir}</span>`)
}).join('') : '<span small secondary>[Please select it]</span>'
return(markup)
}
}
app.registerClass('MailingSheetView', MailingSheetView)
/*
TODO
-> Delete Draft (add in WF, add method in MT, add here)
*/
@@ -0,0 +1,17 @@
<style>
.dups-dialog li.row{ grid-template-columns: 1fr 2fr 5rem;}
.dups-dialog .cell ul{ list-style: none; }
.dups-dialog section.dups section{max-height: 50vh;}
</style>
<div class="dups-dialog">
<section class="dups">
<article eiccard eiccard>
<header>Duplicate emails found across your sources:</header>
<section>
<div data-output="dupsGrid"></div>
</section>
</article>
</section>
</div>
@@ -0,0 +1,47 @@
class MailingDuplicatesDialog extends EICDialogContent {
actions = [
{
label: 'Done',
onclick: this.cancel.bind(this),
severity: 'primary',
role: 'cancel'
}
]
constructor(...args) {
super(...args)
Object.assign(this, app.helpers.activeAttributes)
}
DOMContentLoaded(options) {
let components = ui.eicfy(this.el)
this.setupRefs(components)
this.dupesGrid = new DataGrid(this.outputs.dupsGrid, {
headers: [
{label: 'Email'},
{label: 'Sources'},
{label: 'Row numbers'},
],
actions:[],
})
this.fillDupsGrid(options.dupes)
}
fillDupsGrid(dupes){
let idx=0
for(let email in dupes){
let sources = dupes[email].map( item =>item.sourceName).join('</li><li>')
let rownbs = dupes[email].map( item =>item.rownb+1).join('</li><li>')
let row = this.dupesGrid.addRow(idx, [
email, // expecting source import datestamp
`<ul><li>${sources}</li></ul>`,
`<ul><li>${rownbs}</li></ul>`,
], true)
idx++
}
}
}
app.registerClass('MailingDuplicatesDialog',MailingDuplicatesDialog)
@@ -0,0 +1,15 @@
<style>
.import-dialog{ width: 50vw; }
</style>
<div class="import-dialog">
<section class="import">
<label>Drop or select your Excel file...</label>
<div eicfileupload class="browseBtn"></div>
<article eiccard eiccard collapsable collapsed hidden class="report">
<header><span large class="report-title"></span></header>
<section class="report-details">
</section>
</article>
</section>
</div>
@@ -0,0 +1,251 @@
class MailingExclusionDialog extends EICDialogContent {
actions = [
{
label: 'Cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel'
},
{
label: 'Import & Exclude them',
onclick: this.add.bind(this),
severity: 'primary',
role: 'add',
disabled: true
}
]
constructor(...args) {
super(...args)
Object.assign(this, app.helpers.validators)
}
DOMContentLoaded(options) {
this.model = options.model
this.mid = options.mid
let components = ui.eicfy(this.el)
this.fileUpload = new FileUpload(this.find('.import-dialog .browseBtn'), {
uploadUrl: '#',
uploadMethod: '',
submitLabel:'Analyse file',
allowedExtensions: ['xls','xlsx'],
allowedMimes:['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel'],
allowDrop: true,
maxFiles: 1,
minFiles: 1,
dndLabel: 'Drop your recipents Excel list here !',
noUpload: true,
});
this.fileUpload.onFileAdded = this.onFileAdded.bind(this)
this.fileUpload.onFileRemoved = this.onFileRemoved.bind(this)
this.fileUpload.onWrongFileType = this.onWrongFileType.bind(this)
this.report = this.find('.import-dialog .report')
this.reportTitle = this.find('.import-dialog .report-title')
this.reportDetails = this.find('.import-dialog .report-details')
this.recipientsToImport = {}
}
onFileAdded(fileIdx, file){
let reader = new FileReader()
reader.onload = this.verifyData.bind(this, reader, file)
this.fileUpload.browseBtn.loading = true
reader.readAsArrayBuffer(file)
}
onWrongFileType(file){
ui.show(this.report)
this.fileUpload.browseBtn.loading = false
// because stopping the loading reactivates the button by default (god's will)
if(this.fileUpload.filesList.length==0) this.fileUpload.browseBtn.disabled = false
else this.fileUpload.browseBtn.disabled = true
this.reportTitle.classList.add('error')
this.reportTitle.innerHTML = `Could not import file !`
this.reportDetails.innerHTML = `Wrong file type (${file.type})`
}
verifyData(reader, file){
this.fileUpload.browseBtn.loading = false
// because stopping the loading reactivates the button by default (god's will)
if(this.fileUpload.filesList.length==0) this.fileUpload.browseBtn.disabled = false
else this.fileUpload.browseBtn.disabled = true
let error
let workbook = null
let recipientsArray = null
let recipientsList = null
try{
workbook = XLSX.read(reader.result)
} catch(e) {
console.warn('Trying to parse Excel file:',e)
error = 'Error parsing this as Excel file !'
}
if((!error) && workbook){
let first_sheet = workbook.Sheets[workbook.SheetNames[0]]
try{
recipientsArray = XLSX.utils.sheet_to_json(first_sheet, {
header: 1
})
} catch(e) {
recipientsArray = null
error = 'Error parsing this as Excel sheet !'
}
if(recipientsArray && (!first_sheet['!margins'])) {
recipientsArray = null
error = 'Could not parse this as Excel file !'
}
}
let result = {}
if(recipientsArray){
result = this.getEmailRows(recipientsArray, file)
}
ui.show(this.report)
if(result.recipientsList){
this.reportTitle.classList.remove('error')
this.reportTitle.innerHTML = `Found ${result.recipientsList.length} emails to use as Excusion list!`
let headersList = ''
if(result.availableColumns.some(item => item.hint)){
headersList = result.availableColumns.map(item=>item.hint).join(', ')
}
this.reportDetails.innerHTML = `
<ul>
<li>Column used for the emails: <span success>${result.emailColName}</span></li>
<li><span success>${(result.rejectedRows.length>0) ? result.rejectedRows.length : 'No'}</span> ${ (result.rejectedRows.length==1) ? 'row was' : 'rows where' } rejected because column ${result.emailColName} did not contain an email.</li>
<li>Removed <span success>${result.nbDups}</span> emails that ${result.nbDups.length>1 ? 'where duplicates' : 'was a duplicate'}
</li>
</ul>
`
this.recipientsToImport = result
this.recipientsToImport.sourceName = file.name
this.recipientsToImport.sourceType = 'Excel'
} else {
this.reportTitle.classList.add('error')
this.reportTitle.innerHTML = error
this.reportDetails.innerHTML = ``
this.recipientsToImport = {}
}
if(this.fileUpload.filesList.length==0) this.actions.find(o => o.role == 'add').button.disabled = true
else this.actions.find(o => o.role == 'add').button.disabled = false
}
getEmailRows(recipientsArray, file){
if((!Array.isArray(recipientsArray)) || (recipientsArray.length<1)){
return({
err: 'The parsed Excel is not an array or has no rows !',
recipientsList:[],
importedData:[],
rejectedRows: [],
nbDups: 0,
emailColIdx:null,
emailColName:null,
availableColumns: null,
})
}
//const mailRFC5322Regex = /^([^\x00-\x20\x22\x28\x29\x2c\x2e\x3a-\x3c\x3e\x40\x5b-\x5d\x7f-\xff]+|\x22([^\x0d\x22\x5c\x80-\xff]|\x5c[\x00-\x7f])*\x22)(\x2e([^\x00-\x20\x22\x28\x29\x2c\x2e\x3a-\x3c\x3e\x40\x5b-\x5d\x7f-\xff]+|\x22([^\x0d\x22\x5c\x80-\xff]|\x5c[\x00-\x7f])*\x22))*\x40([^\x00-\x20\x22\x28\x29\x2c\x2e\x3a-\x3c\x3e\x40\x5b-\x5d\x7f-\xff]+|\x5b([^\x0d\x5b-\x5d\x80-\xff]|\x5c[\x00-\x7f])*\x5d)(\x2e([^\x00-\x20\x22\x28\x29\x2c\x2e\x3a-\x3c\x3e\x40\x5b-\x5d\x7f-\xff]+|\x5b([^\x0d\x5b-\x5d\x80-\xff]|\x5c[\x00-\x7f])*\x5d))*$/
//Spot the column where there are the most email-addresses
let colsCount = []
for(let row of recipientsArray){
let colIdx = 0
for(let cell of row){
if(typeof(cell) === null) cell = ''
if(typeof(cell)=='string'){
if(typeof(colsCount[colIdx])=='undefined') colsCount[colIdx] = 0
if(this.validators.isValidEmail(cell)) colsCount[colIdx]++
}
colIdx++
}
}
// cleanup empty slots => don't use "in", or "of" !
for(let idx=0; idx<colsCount.length; idx++){ if(typeof(colsCount[idx])!='number') colsCount[idx]=0 }
// find winning column
let emailCol = colsCount.reduce((acc,val, idx)=>(val>colsCount[acc]?idx:acc),0)
// Now that we know where to look, remove rows without email in that col
let cleanRecipients = []
let rejectedRows = []
let firstValidRow = null
let rowIdx = 1
for(let row of recipientsArray){
let cell = row[emailCol]
if(typeof(cell)!='string'){
rejectedRows.push(rowIdx)
} else {
if(this.validators.isValidEmail(cell)){
cleanRecipients.push(row)
if(!firstValidRow) firstValidRow = rowIdx
}
else rejectedRows.push(rowIdx)
}
rowIdx++
}
// The row before the first valid email row might be headers
let availableColumns
if(firstValidRow>1){
let headersRow = recipientsArray[firstValidRow-2] // rowIdx & firstValidRow start at 1 => row before the first valid is -2 in recipientsArray
availableColumns = headersRow.map( (item, idx) => ({value: idx, hint: ((item.length > 150) ? item.slice(0, 20) + '...' : item ), isEmail: (idx==emailCol) }) )
} else {
availableColumns = recipientsArray[firstValidRow].map( (item, idx) => ({value: idx, hint: '', isEmail: (idx==emailCol) }) )
}
let nodups=[]
const uniqueRecipients = cleanRecipients.filter(item=>{ if(!nodups.includes(item[emailCol])) { nodups.push(item[emailCol]); return(true) } return(false) })
let finalRecipients = cleanRecipients
let recipientsList=[]
for(let recipient of finalRecipients) {
recipientsList.push({
sourceType: "Excel",
sourceName: file.name,
email:recipient[emailCol],
meta:{}, //Empty mappings
})
}
return({
err: false,
recipientsList: recipientsList,
importedData: finalRecipients,
rejectedRows: rejectedRows,
nbDups: cleanRecipients.length - uniqueRecipients.length,
emailColIdx: emailCol,
emailColName: XLSX.utils.encode_col(emailCol),
availableColumns: availableColumns,
})
}
onFileRemoved(fileIdx, file){
if(this.fileUpload.filesList.length==0){ // normally the case as maxFiles=1
this.reportTitle.innerHTML = ''
this.reportDetails.innerHTML = ''
ui.hide(this.report)
this.recipientsToImport = {}
this.actions.find(o => o.role == 'add').button.disabled = true
} else {
this.actions.find(o => o.role == 'add').button.disabled = false
}
}
async add() {
this.actions.find(o => o.role == 'add').button.loading = true
this.recipientsToImport.importDate = (new Date()).toISOString()
await this.model.saveExclusion(this.mid, {
sourceName: this.recipientsToImport.sourceName,
sourceType: this.recipientsToImport.sourceType,
data: this.recipientsToImport.importedData,
availableColumns: this.recipientsToImport.availableColumns,
})
this.commit(true)
}
onFailed() {
this.abort()
}
}
app.registerClass('MailingExclusionDialog',MailingExclusionDialog);
@@ -0,0 +1,75 @@
<style>
.fetch-dialog{ width: 78vw; }
.fetch-dialog .icon-spinner{ top:0; right:1em; }
.fetch-dialog div.field{ margin: .5em 0 0.5em 0; }
.fetch-dialog article.searchCriteria{ max-height:70vh; min-height: 50vh; }
.fetch-dialog article.sourceName header h1, .fetch-dialog article.searchType header h1,
.fetch-dialog article.searchCriteria header h1 {color: var(--eicui-base-color-primary-100);}
.fetch-dialog div.criteria-output{grid-template-columns: 3fr 2fr;}
.fetch-dialog .searchOutput ul{list-style: none; font-size: var(--eicui-base-font-size-s);}
.fetch-dialog header h1 span.icon-info{margin-left: 1em;}
.fetch-dialog .searchCriteria div.cols-2.field-label{grid-template-columns: auto 2em;justify-content: flex-start;}
.fetch-dialog menu.searchCriteriaGroups li { white-space: collapse; width: 8em; }
.fetch-dialog [data-output="resultLegend"] alert {border-width: 1em;}
.fetch-dialog .searchOutput button.collapser{
min-height: calc(var(--eicui-base-spacing-l) + var(--eicui-base-spacing-2xs));
min-width: calc(var(--eicui-base-spacing-l) + var(--eicui-base-spacing-2xs));
}
.fetch-dialog .search-criteria{ grid-template-columns: 1fr 8em; }
.fetch-dialog .sample-refresh{ grid-template-columns: 1fr 1em; }
.fetch-dialog [data-trigger="onRefreshSample"]{ position: absolute; top: 0; right: 1em; }
.fetch-dialog [data-output="resultLegend"] li { padding-top: var(--eicui-base-font-size-2xs); }
.fetch-dialog [data-output="resultLegend"] li .cols-2{ grid-template-columns:1.5em auto; }
.fetch-dialog [data-output="resultLegend"] .color-dot{border-radius: 1em;width: 1.5em; height: 1.5em; display: inline-block; vertical-align: middle; }
.fetch-dialog [data-output="resultLegend"] i{white-space: nowrap; }
.fetch-dialog article.probe-results ul li div.cols-2{ grid-template-columns: 10em auto; }
.fetch-dialog article.probe-results header{ grid-template-columns: auto 4em; }
.fetch-dialog article.probe-results header button {width: 2em; margin-left: 0.2em;}
.fetch-dialog article.probe-results ul li b { background: var(--eicui-base-color-info-25); padding: .2em; margin: .1em 0; }
</style>
<div class="fetch-dialog">
<section>
<div class="cols-2">
<article eiccard class="sourceName">
<header>
<h1>Name for this source in your mailing:</h1>
</header>
<section>
<input eicinput type="text" name="sourceName" placeholder="People in science projects" data-ref="sourceName" data-change="onSourceName">
</section>
</article>
<article eiccard class="searchType">
<header data-async="searchType">
<h1>Type of search</h1>
</header>
<section>
<select eicselect name="searchType" data-ref="searchType" data-change="onsearchType">
<option></option>
</select>
</section>
</article>
</div>
<div class="cols-2 criteria-output">
<article eiccard class="searchCriteria">
<header data-async="searchCriteria">
<h1>Search criteria</h1>
</header>
<section data-output="searchCriteria">
</section>
</article>
<article eiccard class="searchOutput">
<header data-async="searchOutput">
<div class="cols-2 search-criteria">
<h1>Search results</h1>
<button eicbutton small primary data-ref="searchBtn" data-trigger="onFiltersChange" disabled>Search</button>
</div>
</header>
<section data-output="searchOutput">
</section>
</article>
</div>
</section>
</div>
@@ -0,0 +1,349 @@
class MailingFetchDialog extends EICDialogContent {
actions = [
{
label: 'Cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel'
},
{
label: 'Use these recipients',
onclick: this.add.bind(this),
severity: 'primary',
role: 'add',
disabled: true
}
]
constructor(...args) {
super(...args)
Object.assign(this, app.helpers.activeAttributes)
this.currentFilters = []
this.currentQCId = null
this.currentNbResults = 0
this.baseColors = ['success','primary','danger','warning','accent','secondary']
}
DOMContentLoaded(options) {
this.contactsModel = options.models.contacts
this.mailingsModel = options.models.mailings
this.mid = options.mid
const components = ui.eicfy(this.el)
this.setupTriggers(components)
this.setupRefs(components)
this.components.searchType.disabled = true
this.setAsyncLoading(true, ['searchType'])
this.contactsModel.list().then(this.fillQCSelector.bind(this))
}
fillQCSelector(qcList){
for(let query of qcList){
this.components.searchType.el.append(ui.create(
`<option value="${query.id}">${query.title}</option>`
))
}
this.components.searchType.disabled = false
this.setAsyncLoading(false, ['searchType'])
}
onsearchType(component, event){
this.setAsyncLoading(true, ['searchCriteria'])
this.components.searchBtn.disabled = true
this.contactsModel.get(component.value).then(this.buildForm.bind(this))
}
onSourceName(component, event){ this.changeAddBtnState() }
buildForm(query){
this.fieldNames = {}
this.fieldColors = {}
this.output('searchCriteria','')
const tabsMenu = ui.create(`
<menu eictab vertical class="searchCriteriaGroups" xsmall>
${query.group.map(queryGroup => `
<li>
${queryGroup.title}
</li>
`).join('\n')}
</menu>
`)
const tabsDiv = ui.create(`<div class="cols-2 left"></div>`)
this.outputs.searchCriteria.append(tabsDiv)
for(let queryGroup of query.group){
const groupEl = ui.create(`
<article eiccard="" class="tab-content searchCriteriaGroups">
<header>
<h1>${queryGroup.title}
${queryGroup.description ? `<span info="" class="icon-info" title="${queryGroup.description.replace('"','&quot;').replace(/\n/g, ' ')}"></span>`: ''}
</h1>
</header>
<section>
</section>
</article>
`)
const sectionEl = groupEl.querySelector('section')
let colIdx = 0
for(let field of queryGroup.field){
this.fieldNames[field.id] = field.title
this.fieldColors[field.id] = {
severity: this.baseColors[colIdx % this.baseColors.length],
brightness: 100+(20*Math.floor(colIdx/this.baseColors.length))
}
colIdx++
const el = ui.create(`
<div class="cols-2 field">
<div class="cols-2 field-label">
<label title="${field.description ? field.description.replace('"','&quot;'):''}">${field.title}</label>
${field.description ? `<span info="" class="icon-info" title="${field.description.replace('"','&quot;').replace(/\n/g, ' ')}"></span>` : ''}
</div>
</div>
`)
if(!field.type) field.type='text'
switch(field.type){
case 'list':
const selector = ui.create(`<select eicselect multiple name="${field.id}" lookup></select>`)
for(let value of field.value){
selector.append(ui.create(
`<option value="${value}">${value}</option>`
))
}
el.append(selector)
break
case 'date':
el.append(ui.create(`<input eicinput type="date" name="${field.id}">`))
break
default:
el.append(ui.create(`<select eicselect name="${field.id}" editable data-type="array" placeholder="One or several search values (hit enter after each)">`))
}
sectionEl.append(el)
}
tabsDiv.append(groupEl)
}
tabsDiv.prepend(tabsMenu)
const groupsMenu = new Tab(tabsMenu)
groupsMenu.addTabs(tabsDiv.querySelectorAll('menu.searchCriteriaGroups li'), tabsDiv.querySelectorAll('article.searchCriteriaGroups')); //Array.from(this.findAll('menu.searchCriteriaGroups li')).reverse()
const components = ui.eicfy(this.outputs.searchCriteria)
this.setAsyncLoading(false, ['searchCriteria'])
this.filtersForm = new Form(this.outputs.searchCriteria)
this.setupTriggers(components)
this.components.searchBtn.disabled = false
}
onFiltersChange(component, event){
this.currentQCId = this.components.searchType.value
const formValues = this.filtersForm.value
let fieldValues = Object.keys(formValues).map(k => ({id: k, value: formValues[k]}))
this.currentFilters = fieldValues.filter(item => item.value.length>0)
//this.setAsyncLoading(true, ['searchOutput'])
this.components.searchBtn.loading = true
this.contactsModel.getSample(this.currentQCId, this.currentFilters).then(
this.fillSearchOutput.bind(this),
() => this.components.searchBtn.loading = false
//this.setAsyncLoading(false, ['searchOutput'])
)
}
fillSearchOutput(results){
this.components.searchBtn.loading = false
// this.setAsyncLoading(false, ['searchOutput'])
this.output('searchOutput', `
<alert eicalert small muted info>Total recipients: <b>${results.nbRows}</b></alert>
<article eiccard collapsable>
<header eicalert small muted info><h1>Quick Probe</h1></header>
<section>
Email (or part of it)
<input eicinput="" type="search" name="emailProbe" data-ref="probeSearch"/>
<article eiccard class="probe-results"></article>
</section>
</article>
<article eiccard collapsable>
<header eicalert small muted info><h1>Results statistics</h1></header>
<section>
<ul data-output="countersList"></ul>
</section>
</article>
`)
this.outputs.searchOutput.querySelectorAll('[eiccard]').forEach(el => { new Card(el) })
this.outputs.searchOutput.querySelectorAll('[eicbutton]').forEach(el => { new Button(el) })
const probeSearch = new InputSearch(this.outputs.searchOutput.querySelector('[eicinput][type="search"]'))
probeSearch.onQuery = this.onProbe.bind(this,probeSearch)
this.fillCountersLegend(results.counters)
this.currentNbResults = results.nbRows
this.changeAddBtnState()
}
changeAddBtnState(){
if((this.currentNbResults>0) && (this.components.sourceName.value.length>3)) this.actions.find(o => o.role == 'add').button.disabled = false
else this.actions.find(o => o.role == 'add').button.disabled = true
}
onRefreshSample(component){
this.components.sampleRefreshBtn.loading = true
this.contactsModel.getSample(this.currentQCId, this.currentFilters).then(
(results) => {
const el = this.outputs.searchOutput.querySelector('.sample-list')
el.innerHTML = results.rows.map(row => ('<li>'+row.email+'</li>')).join('')
this.components.sampleRefreshBtn.loading = false
},
() => this.components.sampleRefreshBtn.loading = false
)
}
onProbe(component){
component.loading = true
this.setAsyncLoading(false, ['quickprobe'])
this.outputs.searchOutput.querySelector('article.probe-results').innerHTML = ''
this.contactsModel.probe(this.currentQCId, this.currentFilters, component.value).then((payload) =>{
this.fillProbeResults(payload)
component.loading = false
},
() => { component.loading = false }
)
}
fillProbeResults(results){
this.lastProbeResults = results
this.lastProbeIndex = 0
if(results.probeMatchesNumber==0){
this.outputs.searchOutput.querySelector('article.probe-results').innerHTML = `<header>No email found containing that text.</header>`
return
}
this.showProbeResult()
}
showProbeResult(){
this.outputs.searchOutput.querySelector('article.probe-results').innerHTML = ''
this.outputs.searchOutput.querySelector('article.probe-results').append(ui.create(`
<header>
<div>
<span>Found ${this.lastProbeResults.probeMatchesNumber} matching</span>
<span xsmall data-output="probeIndex"></span>
</div>
<span class="next-prev"></span>
</header>
`))
this.outputs.searchOutput.querySelector('article.probe-results').append(ui.create(`<section></section>`))
this.probeMaxIndex = this.lastProbeResults.probeMatchesNumber<20 ? this.lastProbeResults.probeMatchesNumber-1 : 19
this.components.prevProbeShow = new Button(null, {
label:'&lt;',
size: 'xxsmall',
onclick: () => {
this.lastProbeIndex = (this.lastProbeIndex>0) ? this.lastProbeIndex-1 : this.probeMaxIndex
this.showOneProbeMatch()
}
});
this.outputs.searchOutput.querySelector('.next-prev').append(this.components.prevProbeShow.el)
this.components.nextProbeShow = new Button(null, {
label:'&gt;',
size: 'xxsmall',
onclick: () => {
this.lastProbeIndex = (this.lastProbeIndex<(this.probeMaxIndex)) ? this.lastProbeIndex+1 : 0
this.showOneProbeMatch()
}
});
this.outputs.searchOutput.querySelector('.next-prev').append(this.components.nextProbeShow.el)
this.showOneProbeMatch()
}
showOneProbeMatch(){
const row = this.lastProbeResults.probeMatches[this.lastProbeIndex]
let html='<ul>'
for(const key of Object.keys(row)){
html += `<li><div class='cols-2'>
<b>${row[key].title}</b>
<i>${Array.isArray(row[key].value) ? row[key].value.join(', ') : row[key].value}</i>
</div></li>`
}
html += '</ul>'
this.outputs.searchOutput.querySelector('article.probe-results section').innerHTML = ''
this.outputs.searchOutput.querySelector('article.probe-results section').append(ui.create(html))
this.outputs.searchOutput.querySelector('[data-output="probeIndex"]').innerHTML = ` (Showing ${this.lastProbeIndex+1}/${this.probeMaxIndex+1})`
}
fillCountersLegend(counters){
const el = this.outputs.searchOutput.querySelector('[data-output="countersList"]')
el.innerHTML = ''
for(let counterName in counters) {
const liel = ui.create(`
<li>
<article eiccard collapsable collapsed>
<header><div>${counterName} <span eicchip xxsmall>${counters[counterName].nbUnique} unique</span></div></header>
<section>
<div class="cols-2 resultCounter">
<ul data-output="resultLegend"></ul>
<div data-output="resultPie"></div>
</div>
</section>
</alert>
</li>
`)
el.append(liel)
const ulel = liel.querySelector('[data-output="resultLegend"]')
let colIdx = 0
const pieData = []
for(let value in counters[counterName].counters) {
const severity = this.baseColors[colIdx % this.baseColors.length]
const brightness = 100-(20*Math.floor(colIdx/this.baseColors.length))
ulel.append(ui.create(`
<li><div class="cols-2">
<div class="color-dot" style="background: var(--eicui-base-color-${(severity!='secondary') ? severity : 'grey'}-100);filter:brightness(${brightness}%");></div>
<div>
<b>${value}:</b>
<i>${ (100*counters[counterName].counters[value]/counters[counterName].total).toFixed(1) }% (${counters[counterName].counters[value]})</i>
</div>
</div>
</li>
`))
pieData.push({
value: counters[counterName].counters[value],
severity: severity,
brightness: `${brightness}%`,
})
colIdx++
}
new PieTreeChart(liel.querySelector('[data-output="resultPie"]'), {
title: '',
height: 200,
data: pieData,
angularGap: 1,
innerRadius: 0,
outerRadius: 50
})
}
el.querySelectorAll('[eiccard]').forEach(el => { new Card(el) })
}
async add() {
this.actions.find(o => o.role == 'add').button.loading = true
this.currentQCId = this.components.searchType.value
const formValues = this.filtersForm.value
let fieldValues = Object.keys(formValues).map(k => ({id: k, value: formValues[k]}))
this.currentFilters = fieldValues.filter(item => item.value.length>0)
await this.mailingsModel.saveImport(this.mid, {
sourceName: this.components.sourceName.value,
sourceType: 'Marklogic',
data: null,
availableColumns: null,
query_id: this.currentQCId,
query_params: this.currentFilters,
})
this.commit(true)
}
onFailed() {
this.abort()
}
}
app.registerClass('MailingFetchDialog',MailingFetchDialog);
@@ -0,0 +1,23 @@
<style>
.import-dialog{ width: 50vw; }
</style>
<div class="import-dialog">
<section class="import">
<label>Drop or select your Excel file...</label>
<div eicfileupload class="browseBtn"></div>
<article eiccard eiccard>
<section>
<div class="cols-2 switches">
Remove duplicates
<input eicinput type="toggler" name="removeDups" primary="" labelleft="no" labelright="yes" xsmall="" value="yes" aria-enabled="true" classOff="greyed" class="remove-dups"/>
</div>
</section>
</article>
<article eiccard eiccard collapsable collapsed hidden class="report">
<header><span large class="report-title"></span></header>
<section class="report-details">
</section>
</article>
</section>
</div>
@@ -0,0 +1,260 @@
class MailingImportDialog extends EICDialogContent {
actions = [
{
label: 'Cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel'
},
{
label: 'Import recipients',
onclick: this.add.bind(this),
severity: 'primary',
role: 'add',
disabled: true
}
]
constructor(...args) {
super(...args)
Object.assign(this, app.helpers.validators)
}
DOMContentLoaded(options) {
this.model = options.model
this.mid = options.mid
let components = ui.eicfy(this.el)
this.fileUpload = new FileUpload(this.find('.import-dialog .browseBtn'), {
uploadUrl: '#',
uploadMethod: '',
submitLabel:'Analyse file',
allowedExtensions: ['xls','xlsx'],
allowedMimes:['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel'],
allowDrop: true,
maxFiles: 1,
minFiles: 1,
dndLabel: 'Drop your recipents Excel list here !',
noUpload: true,
});
this.fileUpload.onFileAdded = this.onFileAdded.bind(this)
this.fileUpload.onFileRemoved = this.onFileRemoved.bind(this)
this.fileUpload.onWrongFileType = this.onWrongFileType.bind(this)
this.report = this.find('.import-dialog .report')
this.reportTitle = this.find('.import-dialog .report-title')
this.reportDetails = this.find('.import-dialog .report-details')
this.removeDups = components.find(item => (item.el.classList.contains('remove-dups')))
this.recipientsToImport = {}
}
onFileAdded(fileIdx, file){
let reader = new FileReader()
reader.onload = this.verifyData.bind(this, reader, file)
this.fileUpload.browseBtn.loading = true
reader.readAsArrayBuffer(file)
}
onWrongFileType(file){
ui.show(this.report)
this.fileUpload.browseBtn.loading = false
// because stopping the loading reactivates the button by default (god's will)
if(this.fileUpload.filesList.length==0) this.fileUpload.browseBtn.disabled = false
else this.fileUpload.browseBtn.disabled = true
this.reportTitle.classList.add('error')
this.reportTitle.innerHTML = `Could not import file !`
this.reportDetails.innerHTML = `Wrong file type (${file.type})`
}
verifyData(reader, file){
this.fileUpload.browseBtn.loading = false
// because stopping the loading reactivates the button by default (god's will)
if(this.fileUpload.filesList.length==0) this.fileUpload.browseBtn.disabled = false
else this.fileUpload.browseBtn.disabled = true
let error
let workbook = null
let recipientsArray = null
let recipientsList = null
try{
workbook = XLSX.read(reader.result)
} catch(e) {
console.warn('Trying to parse Excel file:',e)
error = 'Error parsing this as Excel file !'
}
if((!error) && workbook){
let first_sheet = workbook.Sheets[workbook.SheetNames[0]]
try{
recipientsArray = XLSX.utils.sheet_to_json(first_sheet, {
header: 1
})
} catch(e) {
recipientsArray = null
error = 'Error parsing this as Excel sheet !'
}
if(recipientsArray && (!first_sheet['!margins'])) {
recipientsArray = null
error = 'Could not parse this as Excel file !'
}
}
let result = {}
if(recipientsArray){
result = this.getEmailRows(recipientsArray, file)
}
ui.show(this.report)
if(result.recipientsList){
this.reportTitle.classList.remove('error')
this.reportTitle.innerHTML = `Ready to import ${result.recipientsList.length} recipients !`
let headersList = ''
if(result.availableColumns.some(item => item.hint)){
headersList = result.availableColumns.map(item=>item.hint).join(', ')
}
this.reportDetails.innerHTML = `
<ul>
<li>Column used for the emails: <span success>${result.emailColName}</span></li>
<li><span success>${(result.rejectedRows.length>0) ? result.rejectedRows.length : 'No'}</span> ${ (result.rejectedRows.length==1) ? 'row was' : 'rows where' } rejected because column ${result.emailColName} did not contain an email.</li>
<li> ${(this.removeDups.value=='yes') ?
`Removed <span success>${result.nbDups}</span> emails that ${result.nbDups.length>1 ? 'where duplicates' : 'was a duplicate'}`:
`<span danger >I detected ${result.nbDups} duplicate${result.nbDups.length>1 ? 's' : ''} !</span> (which I did not remove)`}
</li>
<li><span ${headersList ? 'success': 'warning'}>${headersList ? 'Potential headers row: ': 'None headers found.'}</span>${headersList}</li>
</ul>
`
this.recipientsToImport = result
this.recipientsToImport.sourceName = file.name
this.recipientsToImport.sourceType = 'Excel'
} else {
this.reportTitle.classList.add('error')
this.reportTitle.innerHTML = error
this.reportDetails.innerHTML = ``
this.recipientsToImport = {}
}
if(this.fileUpload.filesList.length==0) this.actions.find(o => o.role == 'add').button.disabled = true
else this.actions.find(o => o.role == 'add').button.disabled = false
}
getEmailRows(recipientsArray, file){
if((!Array.isArray(recipientsArray)) || (recipientsArray.length<1)){
return({
err: 'The parsed Excel is not an array or has no rows !',
recipientsList:[],
importedData:[],
rejectedRows: [],
nbDups: 0,
emailColIdx:null,
emailColName:null,
availableColumns: null,
})
}
//const mailRFC5322Regex = /^([^\x00-\x20\x22\x28\x29\x2c\x2e\x3a-\x3c\x3e\x40\x5b-\x5d\x7f-\xff]+|\x22([^\x0d\x22\x5c\x80-\xff]|\x5c[\x00-\x7f])*\x22)(\x2e([^\x00-\x20\x22\x28\x29\x2c\x2e\x3a-\x3c\x3e\x40\x5b-\x5d\x7f-\xff]+|\x22([^\x0d\x22\x5c\x80-\xff]|\x5c[\x00-\x7f])*\x22))*\x40([^\x00-\x20\x22\x28\x29\x2c\x2e\x3a-\x3c\x3e\x40\x5b-\x5d\x7f-\xff]+|\x5b([^\x0d\x5b-\x5d\x80-\xff]|\x5c[\x00-\x7f])*\x5d)(\x2e([^\x00-\x20\x22\x28\x29\x2c\x2e\x3a-\x3c\x3e\x40\x5b-\x5d\x7f-\xff]+|\x5b([^\x0d\x5b-\x5d\x80-\xff]|\x5c[\x00-\x7f])*\x5d))*$/
//Spot the column where there are the most email-addresses
let colsCount = []
for(let row of recipientsArray){
let colIdx = 0
for(let cell of row){
if(typeof(cell) === null) cell = ''
if(typeof(cell)=='string'){
if(typeof(colsCount[colIdx])=='undefined') colsCount[colIdx] = 0
if(this.validators.isValidEmail(cell)) colsCount[colIdx]++
}
colIdx++
}
}
// cleanup empty slots => don't use "in", or "of" !
for(let idx=0; idx<colsCount.length; idx++){ if(typeof(colsCount[idx])!='number') colsCount[idx]=0 }
// find winning column
let emailCol = colsCount.reduce((acc,val, idx)=>(val>colsCount[acc]?idx:acc),0)
// Now that we know where to look, remove rows without email in that col
let cleanRecipients = []
let rejectedRows = []
let firstValidRow = null
let rowIdx = 1
for(let row of recipientsArray){
let cell = row[emailCol]
if(typeof(cell)!='string'){
rejectedRows.push(rowIdx)
} else {
if(this.validators.isValidEmail(cell)){
cleanRecipients.push(row)
if(!firstValidRow) firstValidRow = rowIdx
}
else rejectedRows.push(rowIdx)
}
rowIdx++
}
// The row before the first valid email row might be headers
let availableColumns
if(firstValidRow>1){
let headersRow = recipientsArray[firstValidRow-2] // rowIdx & firstValidRow start at 1 => row before the first valid is -2 in recipientsArray
availableColumns = headersRow.map( (item, idx) => ({value: idx, hint: ((item.length > 150) ? item.slice(0, 20) + '...' : item ), isEmail: (idx==emailCol) }) )
} else {
availableColumns = recipientsArray[firstValidRow].map( (item, idx) => ({value: idx, hint: '', isEmail: (idx==emailCol) }) )
}
let nodups=[]
const uniqueRecipients = cleanRecipients.filter(item=>{ if(!nodups.includes(item[emailCol])) { nodups.push(item[emailCol]); return(true) } return(false) })
let finalRecipients
if(this.removeDups.value=='yes'){
finalRecipients = uniqueRecipients
} else {
finalRecipients = cleanRecipients
}
let recipientsList=[]
for(let recipient of finalRecipients) {
recipientsList.push({
sourceType: "Excel",
sourceName: file.name,
email:recipient[emailCol],
meta:{}, //Empty mappings
})
}
return({
err: false,
recipientsList: recipientsList,
importedData: finalRecipients,
rejectedRows: rejectedRows,
nbDups: cleanRecipients.length - uniqueRecipients.length,
emailColIdx: emailCol,
emailColName: XLSX.utils.encode_col(emailCol),
availableColumns: availableColumns,
})
}
onFileRemoved(fileIdx, file){
if(this.fileUpload.filesList.length==0){ // normally the case as maxFiles=1
this.reportTitle.innerHTML = ''
this.reportDetails.innerHTML = ''
ui.hide(this.report)
this.recipientsToImport = {}
this.actions.find(o => o.role == 'add').button.disabled = true
} else {
this.actions.find(o => o.role == 'add').button.disabled = false
}
}
async add() {
this.actions.find(o => o.role == 'add').button.loading = true
this.recipientsToImport.importDate = (new Date()).toISOString()
await this.model.saveImport(this.mid, {
sourceName: this.recipientsToImport.sourceName,
sourceType: this.recipientsToImport.sourceType,
data: this.recipientsToImport.importedData,
availableColumns: this.recipientsToImport.availableColumns,
})
this.commit(true)
}
onFailed() {
this.abort()
}
}
app.registerClass('MailingImportDialog',MailingImportDialog);
@@ -0,0 +1,10 @@
<style>
.mapping-dialog{ width: 50vw; }
</style>
<div class="mapping-dialog">
<label>Select a column for<span eicchip info xsmall>${token}</span></label>
<select eicselect name="column" small></select>
<div eicalert warning small data-output="warning"></div>
<div eicdatagrid class="sample-grid" footer="hidden"></div>
</div>
@@ -0,0 +1,129 @@
class MailingMappingDialog extends EICDialogContent {
actions = [
{
label: 'Cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel'
},
{
label: 'Link',
onclick: this.link.bind(this),
severity: 'primary',
role: 'link',
disabled: false
}
]
DOMContentLoaded(options) {
this.model = options.model
this.token = options.token
this.sourceId = options.sourceId
this.currentMailing = options.currentMailing
this.oneMapping = this.currentMailing.mappings.find(item => item.sourceId==options.sourceId) || { mappings: {} }
this.oneImport = this.currentMailing.imports.find(item => item.sourceId == options.sourceId)
let value = this.oneMapping.mappings[this.token] ? this.oneMapping.mappings[this.token].value : null
let components = ui.eicfy(this.el)
this.warning = this.find('[data-output="warning"]')
ui.hide(this.warning)
this.selector = components.find(component => component.el.name == 'column')
for(let column of this.oneImport.availableColumns ) {
let opt = `<option value="${column.value}">Column ${XLSX.utils.encode_col(column.value)}`
if(column.hint) {
opt += ` (${(column.hint.length<51) ? column.hint : column.hint.slice(0,30)+'..…'})`
}
opt +='</option>'
this.selector.el.append(ui.create(opt))
}
this.sampleGrid = new DataGrid(this.find('.sample-grid'), {
headers: [ {label: 'Sample data'} ],
height: '20vh'
})
this.selector.value = value
this.selector.change(this.onColChange.bind(this));
this.onColChange()
this.btResample = new Button(null, {icon: 'icon-refresh', rounded: true, size: 'xsmall', hint: 'Resample'})
this.btResample.click = this.onColChange.bind(this)
this.sampleGrid.el.querySelector('header .cell.actions').append(this.btResample.el)
}
onColChange(event){
const size = Math.min(10, this.oneImport.dataCount)
const colIdx = this.selector.value
let warnings = []
this.sampleGrid.clear()
if(this.selector.value){
for(let[idx, row] of this._makeSample(this.oneImport.data).entries()) {
let value = String(row[colIdx] || '').trim() || `<span eicchip xsmall danger>Empty !</span>`
this.sampleGrid.addRow( idx, [ value ] )
}
if(this.oneImport.data.some(row => (row[colIdx].trim()==''))){
warnings.push('<div>Some value in this column are empty !</div>')
}
if(this.oneImport.availableColumns.find(c => c.value == colIdx).isEmail) {
warnings.push('<div>Column used as recipients email address !</div>')
}
let tokens = []
for(let token in this.oneMapping.mappings) {
if(this.oneMapping.mappings[token].value == colIdx && token != this.token) tokens.push(token)
}
if(tokens.length > 0) {
warnings.push(`<div>Column already mapped to ${tokens.join(', ')} !</div>`)
}
}
this.warning.innerHTML = ''
ui.hide(this.warning)
if(warnings.length > 0) {
ui.show(this.warning)
this.warning.innerHTML = warnings.join('')
}
}
_makeSample(arr){
let sample = []
let noDups = []
for(let i=0; i<Math.min(10, arr.length); i++){
let rowNb=Math.floor(Math.random() * arr.length)
if(noDups.includes(rowNb)){
i--
} else {
sample.push(arr[rowNb])
noDups.push(rowNb)
}
}
return(sample)
}
async link() {
const colIdx = parseInt(this.selector.value)
if(!Number.isNaN(colIdx)) {
this.actions.find(o => o.role == 'link').button.loading = true
let curmap = this.currentMailing.mappings.find(item => item.sourceId==this.sourceId )
curmap.mappings[this.token] = { value: colIdx, hint: this.oneImport.availableColumns[colIdx].hint }
await this.model.save(this.currentMailing)
this.commit(true)
} else {
this.commit(false)
}
}
}
app.registerClass('MailingMappingDialog',MailingMappingDialog);
@@ -0,0 +1,74 @@
<style>
.template-selection .cols-2.left{ grid-template-columns: 400px auto; }
.template-selection .file-browser{ height: 500px; }
.template-selection .preview { height: 75vh; }
.template-selection .file-browser .dataset {
min-width: 30vw;
max-width: 40vw;
height: 60vh;
}
.template-selection .file-browser [eicdatagrid] .row { grid-template-columns: 60px auto 80px; }
.template-selection .preview .empty:after {
background-color: var();
position: absolute;
top: 0;
left: 0;
}
.template-selection .preview .mail-subject {
padding: var(--eicui-base-spacing-s);
border: 1px solid var(--eicui-base-color-grey-10);
margin: var(--eicui-base-spacing-xs) 0 var(--eicui-base-spacing-m) 0;
}
.template-selection .preview .mail-content {
min-width: 40vw;
width: 1fr;
height: 50vh;
overflow: auto;
border: 1px solid var(--eicui-base-color-grey-10);
}
.template-selection .confirmation {
display: none;
}
.template-selection .file-browser [eicdatagrid] .dataset .row .cell:nth-child(2),
.template-selection .file-browser [eicdatagrid] .dataset .row .cell:nth-child(4) {
text-align: center;
}
.template-selection [eicdatagrid] .row.selected{
background-color: var(--eicui-base-color-info);
color: white;
}
.template-selection [eicdatagrid] .row.selected .ffs.icon-new { color:white }
.template-selection [data-output="templateWarning"]{ display:none; }
</style>
<div class="template-selection">
<div class="cols-2 left">
<article eiccard class="file-browser">
<header>
<h1>Templates</h1>
<h2 data-output="path"></h2>
</header>
<section>
<div eicdatagrid header="hidden" footer="hidden"></div>
</section>
</article>
<article eiccard class="preview">
<header>
<h1 data-output="template"></h1>
<div eicalert warning data-output="templateWarning"></div>
</header>
<section class="empty">
<label>Subject</label>
<div class="mail-subject"></div>
<label>Message</label>
<div class="mail-content html"></div>
</section>
</article>
</div>
<div class="confirmation">
<div class="cols-2 left">
<input eicinput type="checkbox" value="yes">
<span>Yes, replace previous content with this template</span>
</div>
</div>
</div>
@@ -0,0 +1,187 @@
/**
* Todos
* - complete change() mmethod when template selected
*/
class MailingTemplatePreviewDialog extends EICDialogContent {
actions = [
{
label: 'Cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel'
},
{
label: 'Use',
onclick: this.change.bind(this),
severity: 'primary',
role: 'change',
disabled: true
}
]
constructor(...args) {
super(...args)
Object.assign(this, app.helpers.validators)
Object.assign(this, app.helpers.activeAttributes)
}
DOMContentLoaded(options) {
this.templateModel = options.templateModel
this.mailingModel = options.mailingModel
this.currentMailing = options.currentMailing
this.currentTemplateInfo = {...this.currentMailing.template}
this.templateStatus = options.status
const components = ui.eicfy(this.el)
this.setupTriggers(components)
this.setupRefs(components)
this.fileBrowser = new DataGrid(this.find('.file-browser [eicdatagrid]'), {
headers: [
{label: '', filter: null, sortable:false},
{label: 'name', filter: 'text', sortable:true},
],
})
this.fileBrowser.onRowClick = this.onPathChange.bind(this)
this.fileBrowser.loading = true
this.preview = new Card(this.find('.preview'))
this.shadowRoot = this.find('.mail-content.html').attachShadow({ mode: 'open' })
this.refreshFolder(true)
}
showPath(path){
this.currentPath = path
const dirs = path.split('/').filter(item=>item)
const pathChips = dirs.map((dir, idx) => {
const path = dirs.slice(0, idx+1).join('/')
return(`<span eicchip xxsmall info data-path="/${path}">${dir}</span>`)
}).join('')
this.output('path', pathChips)
}
fillFolder(templates) {
this.fileBrowser.clear()
this.fileBrowser.loading = false
this.showPath(this.templateModel.ffs.currentPath)
for(let folder of this.templateModel.ffs.getFolders()){
let row = this.fileBrowser.addRow('./'+folder.name,
[
`<span warning xsmall class="ffs icon-folder"></span>`,
folder.name,
])
row.dataset.type = 'folder'
}
for(let template of templates){
if(template.path == this.templateModel.ffs.currentPath){ // Important on 1st display whne we havent't CD yet
let row = this.fileBrowser.addRow(template.id, [
`<span info xsmall class="ffs icon-new"></span>`,
template.name.length>35 ? template.name.slice(0,35)+'...' : template.name
])
row.dataset.type = 'file'
}
}
this.fileBrowser.loading = false
if(this.currentTemplateInfo && this.currentTemplateInfo.id) this.onFileSelect(this.fileBrowser.getRowById(this.currentTemplateInfo.id))
}
async refreshFolder(pathToTemplate){
this.fileBrowser.clear()
this.fileBrowser.loading = true
let templates
if(pathToTemplate){ // called from DOMContentLoaded => position to current templat, otherwise, let user move around)
templates = await this.templateModel.search([this.currentTemplateInfo.path || '/'], this.templateStatus )
this.templateModel.ffs.changeDir(this.currentTemplateInfo.path || '/')
} else {
templates = await this.templateModel.search([this.templateModel.ffs.currentPath], this.templateStatus )
}
this.fillFolder(templates)
}
onPathChange(row){
if(row.dataset.type=='file') {
this.onFileSelect(row)
} else if(row.dataset.type=='folder') {
this.onFolderSelect(row.dataset.id)
}
}
async onFileSelect(row){
if(!row) return
this.preview.loading = true
this.fileBrowser.rows().forEach(el => el.classList.remove('selected'))
this.currentTemplateInfo = await this.templateModel.get(row.dataset.id)
if(this.currentTemplateInfo.id){
this.currentTokens = this.templateModel.getTokens(this.currentTemplateInfo)
row.classList.add('selected')
this.refreshTemplate(this.currentTemplateInfo)
}
this.preview.loading = false
}
onFolderSelect(path){
this.templateModel.ffs.changeDir(path)
this.refreshFolder(false)
}
refreshTemplate(templateInfo){
const filterText = (text) => {
let element = document.createElement('div')
element.textContent = text
text = element.innerHTML
text = text.length > 150 ? text.slice(0, 100) + '...' : text
return text
}
this.output('template', templateInfo.name.length>35 ? templateInfo.name.slice(0,35)+'...' : templateInfo.name)
this.outputs['template'].setAttribute('title', templateInfo.name)
let subject
if((!templateInfo.meta.subject) || (!templateInfo.meta.subject.trim())){
this.output('templateWarning', 'This template has no subject and cannot be used here !')
ui.show(this.outputs['templateWarning'])
subject = ''
this.actions.find(o => o.role == 'change').button.disabled = true
} else {
ui.hide(this.outputs['templateWarning'])
subject = filterText(templateInfo.meta.subject || '')
this.find('.mail-subject').innerHTML = `${subject.replace(this.validators.mustacheTokenRegex,'<span class="token" eicchip success>$1</span>')}`
this.actions.find(o => o.role == 'change').button.disabled = false
}
let html = atob(templateInfo.html).replace(this.validators.mustacheTokenRegex,'<span class="token" eicchip success>$1</span>')
this.shadowRoot.innerHTML = `
<style>
span.token[eicchip]{
border-radius: 60px !important;
background-color: var(--eicui-base-color-success-100);
color: var(--eicui-base-color-white);
font-size: 0.75rem;
padding: var(--eicui-base-spacing-2xs) var(--eicui-base-spacing-s);
min-height: var(--eicui-base-spacing-xl);
display: inline-grid;
grid-template-columns: ;
min-content min-content min-content;
min-height: 0;
align-items: center;
margin: 1px var(--eicui-base-spacing-2xs);
pointer-events: all;
white-space: nowrap;
position: relative;
text-shadow: none;
}
</style>
${html}`
}
async change() {
this.currentMailing.template = { id: this.currentTemplateInfo.id }
this.actions.find(o => o.role == 'change').button.loading = true
await this.mailingModel.save(this.currentMailing)
this.commit(true)
}
}
app.registerClass('MailingTemplatePreviewDialog',MailingTemplatePreviewDialog);
@@ -0,0 +1,16 @@
<div class="test-dialog">
<section class="test">
<article eiccard eiccard>
<header>Give a valid email address for the recipient of this test :</header>
<section class="test-email">
<input eicinput type="text" name="email" eicinput value="" class="mail-address">
</section>
</article>
<article eiccard eiccard class="tokens-form">
<header>Give values for variables :</header>
<section class="test-tokens">
</section>
</article>
</section>
</div>
@@ -0,0 +1,89 @@
class MailingTestDialog extends EICDialogContent {
actions = [
{
label: 'Cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel',
},
{
label: 'Send test',
onclick: this.send.bind(this),
severity: 'primary',
role: 'send',
disabled: true
}
]
constructor(...args) {
super(...args)
Object.assign(this, app.helpers.validators)
}
DOMContentLoaded(options) {
this.model = options.model
let components = ui.eicfy(this.el)
this.form = new Form(this.find('.test-dialog'));
this.mailInput = components.find(item => item.el.classList.contains('mail-address'))
this.mailInput.el.addEventListener('keyup', this.checkEmail.bind(this))
this.mailInput.el.addEventListener('focus', this.checkEmail.bind(this))
this.mailInput.el.addEventListener('blur', this.checkEmail.bind(this))
this.mailInput.value = app.User.identity.email
this.tokensForm = components.find(item => item.el.classList.contains('tokens-form'))
if(options.tokens.length>0){
ui.show(this.tokensForm.el, 'flex')
this.fillTokenForm(options.tokens)
} else {
ui.hide(this.tokensForm.el)
}
if(this.validators.isValidEmail(this.mailInput.value)) this.actions[1].disabled = false
}
fillTokenForm(tokens){
let markup = `<ul>
<li>
<div class="cols-2">
<label>Token</label>
<label>Value</label>
</div>
</li>`
tokens.forEach(token => {
markup += `<li>
<div class="cols-2">
<div><span eicchip small success>${token}</span></div>
<input eicinput type="text" name="${token}" data-path="tokenValues" eicinput value="" class="token-value">
</div>
</li>
`
})
markup += `</ul>`
this.tokensForm.el.innerHTML = markup
}
checkEmail(event){
if(event) {
event.preventDefault()
event.stopPropagation()
}
if(this.validators.isValidEmail(this.mailInput.value)){
this.actions.find(o => o.role == 'send').button.disabled = false
} else {
this.actions.find(o => o.role == 'send').button.disabled = true
}
}
send() {
if(!this.validators.isValidEmail(this.mailInput.value)) return
let data = this.form.value
console.log(data)
this.commit(data)
}
onFailed() {
this.abort()
}
}
app.registerClass('MailingTestDialog',MailingTestDialog);
@@ -0,0 +1,13 @@
<article eiccard eiccard collapsable collapsed hidden class="pub-decision">
<section>
<div class="forms decision-dialog">
<div class="eicui-input-container decision">
<label>You're about to request a review for this template.</label>
<label>Remarks or comments for the reviewer:</label>
<textarea eictextarea hint="Please don't be gross." name="decision" eicinput value="" class="tpl-decision"></textarea>
</div>
</div>
</section>
</article>
@@ -0,0 +1,51 @@
class TemplatesDecisionDialog extends EICDialogContent {
actions = [
{
label: 'Cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel'
},
{
label: 'Validate',
onclick: this.validate.bind(this),
severity: 'primary',
role: 'validate',
disabled: true
}
]
constructor() {
super()
Object.assign(this, app.helpers.validators)
}
DOMContentLoaded(options) {
this.form = new Form(this.find('.decision-dialog'));
this.decisionTextarea = this.find('.tpl-decision');
this.decisionTextarea.addEventListener('keyup', this.checkDecisionTextarea.bind(this))
}
checkDecisionTextarea(event) {
event.preventDefault()
event.stopPropagation()
if (this.decisionTextarea.value.trim().length > 0) {
this.actions.find(o => o.role == 'validate').button.disabled = false
}
}
validate() {
let data = { comment: this.decisionTextarea.value }
this.commit(data)
}
onFailed() {
this.abort()
}
}
app.registerClass('TemplatesDecisionDialog', TemplatesDecisionDialog);
@@ -0,0 +1,35 @@
<style>
.tpl.testMail {
color: var(--eicui-base-color-info-110);
font-family: var(--eicui-base-font-family);
font-size: var(--eicui-base-font-size-l);
font-weight: 500;
letter-spacing: -.25px;
border-bottom: 1px solid var(--eicui-base-color-grey-25);
}
.tpl .test-email {
display: flex;
flex-direction: column;
gap: 5px;
}
.tpl .mail-address {
width: 30em;
}
</style>
<article eiccard eiccard collapsable collapsed hidden class="tpl testMail">
<header>
<h3>Sending a test email</h3>
</header>
<section>
<div class="tpl test-mail-dialog">
<div class="eicui-input-container test-email">
<div>Enter a valid email address</div>
<div>
<input eicinput type="text" name="email" eicinput value="" class="tpl mail-address">
</div>
</div>
</div>
</section>
</article>
@@ -0,0 +1,63 @@
class TemplatesMailTestDialog extends EICDialogContent {
actions = [
{
label: 'Cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel'
},
{
label: 'Send test',
onclick: this.add.bind(this),
severity: 'primary',
role: 'add',
disabled: true
}
]
constructor() {
super()
Object.assign(this, app.helpers.validators)
}
DOMContentLoaded() {
this.form = new Form(this.find('.test-mail-dialog'))
this.mailInput = this.find('.mail-address')
this.mailInput.addEventListener('keyup', this.checkEmail.bind(this))
// Use user email by default
this.mailInput.value = app.User.identity.email || ''
// force validation on init
setTimeout(() => this.checkEmail(), 0)
}
checkEmail(event = null){
if(event){
event.preventDefault()
event.stopPropagation()
}
const addAction = this.actions.find(o => o.role === 'add')
if (!addAction || !addAction.button) {
return
}
const isValid = this.validators.isValidEmail(this.mailInput.value)
addAction.button.disabled = !isValid
}
add() {
if(!this.validators.isValidEmail(this.mailInput.value)) return
let data = { email: this.mailInput.value }
this.commit(data)
}
onFailed() {
this.abort()
}
}
app.registerClass('TemplatesMailTestDialog', TemplatesMailTestDialog)
@@ -0,0 +1,49 @@
<style>
.uplImg h6 {
margin-top: auto;
}
.uplImg .uplImg-AllowedExt {
text-align: center;
}
.dialog-spinner {
display: flex;
align-items: center;
justify-content: center;
padding: 1em;
font-size: 1.1em;
color: #333;
}
.eic-spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
width: 24px;
height: 24px;
animation: spin 1s linear infinite;
margin-right: 10px;
}
@keyframes spin {
0% { transform: rotate(0deg);}
100% { transform: rotate(360deg);}
}
</style>
<article eiccard eiccard collapsable collapsed hidden class="uplImg">
<header>
<h6>Upload images </h6>
<span>Allowed extensions:</span>
<div class="uplImg-AllowedExt">
<span eicbadge xlarge success>jpg</span>
<span eicbadge xlarge success>jpeg</span>
<span eicbadge xlarge success>png</span>
<span eicbadge xlarge success>svg</span>
</div>
</header>
<section>
<div class="forms upload-img-dialog">
<div class="eicui-input-container upload-img">
<div eicfileupload class="browseBtn"></div>
</div>
</div>
</section>
</article>
@@ -0,0 +1,112 @@
class TemplatesUplImgDialog extends EICDialogContent {
constructor() {
super();
Object.assign(this, app.helpers.validators); // Inject validators
this.actions = [
{
label: 'Cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel'
}
];
}
DOMContentLoaded() {
const imgId = crypto.randomUUID()
const host = window.location.hostname
let envUrl = (host.split('.')[1] !== 'eismea') ? host.split('.')[1] + '.' : ''
// Initialize file upload
this.fileUpload = new FileUpload(this.find('.browseBtn'), {
uploadId: imgId,
uploadUrl: `https://myeic.${envUrl}eismea.eu/public/images/common/${imgId}`,
uploadMethod: 'PUT',
allowedExtensions: ['jpg', 'jpeg', 'png', 'svg'],
allowedMimes: ['image/jpeg', 'image/pjpeg', 'image/png', 'image/svg+xml'],
allowDrop: true,
maxFiles: 1,
minFiles: 1,
dndLabel: 'Drop your image here !',
imageTitle: '',
noUpload: false,
})
const dropArea = this.find('.file-drop-area')
const uplButton = this.find('.uploadBtn.custom-class')
const fileUpl = this.el.querySelector('.file-upload')
dropArea.addEventListener('change', () => {
const existingInput = fileUpl.querySelector('.tpl-imgTitle')
if (!existingInput) {
const inputTitle = ui.create(`
<input eicinput type="text" name="title" class="tpl-imgTitle" placeholder="title of your image" required>
`)
dropArea.appendChild(inputTitle)
// Desabled upload button if input Title empty
uplButton.disabled = true
// Add listener to input Title
inputTitle.addEventListener('input', () => {
const titleValue = inputTitle.value.trim()
if (titleValue) {
inputTitle.classList.remove('highlight')
uplButton.disabled = false
} else {
inputTitle.classList.add('highlight')
uplButton.disabled = true
}
this.fileUpload.options.imageTitle = inputTitle.value
})
}
})
// Event upload button
uplButton.addEventListener('click', () => {
this.fileUpload.onFileAdded.bind(this)
this.addFile()
})
}
// Handle File Added
addFile() {
const fileUpL = this.fileUpload
const uplUrl = fileUpL.options?.uploadUrl
const extension = uplUrl.split('.').pop()
const uplId = fileUpL.options?.uploadId
const fileName = uplId+'.'+extension
let data = {
id: uplId,
name: fileName,
type: extension,
path: '/common',
imageTitle: fileUpL.options?.imageTitle,
meta:'',
}
this.commit(data)
}
commit(data) {
this.result = data
// NE PAS appeler this.cancel() ou this.close() ici
}
showDialogSpinner(show = true) {
let spinner = this.find('.dialog-spinner')
if (!spinner && show) {
spinner = document.createElement('div')
spinner.className = 'dialog-spinner'
spinner.innerHTML = `<span class="eic-spinner"></span> Uploading...`
this.el.appendChild(spinner)
}
if (spinner) spinner.style.display = show ? 'block' : 'none'
}
}
// Register the class
app.registerClass('TemplatesUplImgDialog', TemplatesUplImgDialog)
@@ -0,0 +1,421 @@
<style>
.templateEditor header {
background: url('/app/assets/images/cards/templitorHeader.png') center/cover no-repeat;
}
.templateEditor header h1 {
align-items: center;
border-bottom: 1px solid var(--eicui-base-color-grey-15);
margin-bottom: -1px;
min-height: calc(3 * var(--eicui-base-spacing-m));
padding: calc(var(--eicui-base-spacing-3xl) * 2) var(--eicui-base-spacing-m) var(--eicui-base-spacing-s) var(--eicui-base-spacing-m);
display: block;
color: var(--eicui-base-color-white) !important;
text-shadow: 0 0 4px black;
font-size: x-large !important;
background-size: cover;
transition: all 0.4s;
}
article.templateEditor section{
padding-top: 0;
flex: 1;
}
.eicui-input-container.sticky {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background: white; /* ou autre fond pour lisibilité */
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.templateEditor .template-field {
border: 1px solid var(--eicui-base-color-grey-10);
overflow: auto;
padding: 0.5em;
background-color: #fff;
}
.templateEditor .tplOpStartDate,
.templateEditor .tplOpEndDate {
width: 15em;
}
.tpl-editor .tplActions {
text-align: center;
}
.tpl-editor [eicapp] [eicrichtext] {
padding: var(--eicui-base-spacing-xs);
border: 1px solid var(--eicui-base-color-grey-10);
}
.tpl-editor .template-field.subject label {
font-style: italic;
}
.tpl-editor .file-input {
display: none;
}
.tpl-editor .tpl-edit-area {
width: 60vw;
}
.tpl-editor .path-container {
display: flex;
flex-direction: column;
gap: 5px;
margin-left: 5em;
align-items: flex-start;
}
.tpl-editor .input-button-wrapper {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-start;
width: 100%;
}
.tpl-editor .tplPath {
flex: 1;
min-width: 200px;
}
.tpl-editor .tpl-btn-path {
display: flex;
align-items: center;
justify-content: center;
padding: 5px 10px;
}
.tpl-editor .hidden {
display: none;
}
.tpl-editor .icon-spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.tpl-editor .tpl-spinButton{
border: 0;
}
.tpl-editor .hiddenInput {
position: absolute;
opacity: 0;
pointer-events: none;
width: 0;
}
@keyframes dots {
0% { content: "Loading"; }
33% { content: "Loading ."; }
66% { content: "Loading .."; }
100% { content: "Loading ..."; }
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.tpl-editor input[eicinput]{
height: 2em;
padding-bottom: 0.5em;
}
.tpl-editor .tpl-loadingTools {
font-size: 16px;
font-weight: bold;
color: #555;
animation: blink 1s infinite;
}
.tpl-editor .tpl-loadingTools::after {
content: "Loading";
animation: dots 1.5s infinite steps(1);
display: inline-block;
}
.tpl-editor .delete-img [eicbadge]{
position: relative;
}
.tpl-editor .he-imgSelectContainer{
width: 20%;
}
.tpl-editor .tpl-resizer {
resize: both;
overflow: hidden;
display: inline-block;
max-width: 100%;
}
.tpl-editor .he-imgItem.selected {
border: 2px solid blue;
background-color: lightgray;
}
.tpl-editor .he-imgItem {
transition: background-color 0.3s ease;
}
.tpl-editor .he-imgItem:hover {
background-color: #f0f0f0;
}
.tpl-editor .he-color-palette {
display: grid;
grid-template-columns: repeat(3, 1.5em);
gap: 0.5em;
position: fixed;
border: 1px solid #ccc;
padding: 2px;
background-color: #fff;
z-index: 1000;
}
.tpl-editor .he-colorOption {
width: 1.5em;
height: 1.5em;
cursor: pointer;
pointer-events: auto;
}
.tpl-editor .he-colorOption:hover {
border: 2px solid #000;
}
.tpl-editor .tpl-table {
border-collapse: collapse;
}
.tpl-editor .tpl-table td {
border: 1px solid #ccc;
padding: 10px;
position: relative;
}
.tpl-editor .he-listItems{
min-height: 100px;
}
.tpl-editor .tpl-history {
max-height: 12em;
overflow-y: auto;
}
.tpl-editor .tpl-history .data-set .row {
display: inline-block;
line-height: 1px;
}
.tpl-editor .template-history .footer {
font-size: x-small;
}
.tpl-editor .tpl-fullName,
.tpl-editor .tpl-comment {
display: grid;
}
.tpl-editor .tpl-comment {
white-space: normal;
word-wrap: break-word;
}
.tpl-editor .error-message {
color: red;
padding: 1em;
font-weight: bold;
text-align: center;
}
.tpl-editor .empty-message {
color: #999;
padding: 1em;
text-align: center;
}
.tpl-edit-area {
width: 100%;
position: relative;
}
.eicui-input-container {
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
}
.tpl-edit-area {
position: relative;
width: 100%;
}
.tpl-history-wrapper {
position: absolute;
top: 0;
right: 0;
z-index: 10;
}
.tpl-history-wrapper .toggle-history {
margin-left: auto;
}
.tpl-history {
position: absolute;
top: 2.5em;
right: 0;
width: 15em;
overflow-y: auto;
background: white;
border: 1px solid var(--eicui-base-color-grey-10);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 1000;
opacity: 0;
transform: translateX(100%);
pointer-events: none;
transition: transform 0.3s ease, opacity 0.3s ease;
}
.tpl-history:not(.collapsed) {
transform: translateX(0);
opacity: 1;
pointer-events: auto;
}
.tpl-edit-area .template-field {
max-height: 45vh;
}
@media (max-height: 700px) {
.tpl-edit-area .template-field {
max-height: 55vh;
}
}
@media (min-height: 900px) {
.tpl-edit-area .template-field {
max-height: 65vh;
}
}
.tpl-edit-area .template-field.preview{
max-height: unset;
}
</style>
<article eiccard class="templateEditor">
<header>
<h1>Template Designer</h1>
</header>
<section>
<div class="tpl-editor">
<article eiccard class="tplEditor" tplEditor>
<section>
<div class="cols-3">
<div>
<label small>Template name</label>
<input eicinput class="tplName tpl-inputMeta" name="tplName"
placeholder="your template name..." />
</div>
<div class="cols-2 right">
<div class="tplOpStartDate">
<label small>Operating start date</label>
<input eicinput type="text" class="tpl-formattedDateStart tpl-inputMeta" data-trigger="onStartDateClicked" />
<input type="datetime-local" class="tplStartDate tpl-inputMeta hiddenInput" data-trigger="syncDateInputs"/>
</div>
<div class="tplOpEndDate">
<label small>Operating end date</label>
<input eicinput primary type="text" class="tpl-formattedDateEnd tpl-inputMeta" data-trigger="onEndDateClicked"/>
<input type="datetime-local" class="tplEndDate tpl-inputMeta hiddenInput" data-trigger="syncDateInputs"/>
</div>
</div>
<div class="path-container">
<label small>Template path</label>
<div class="input-button-wrapper">
<span eicchip secondary><label class="tplPath tpl-inputMeta"
name="tplPath"></label></span>
<button eicbutton xsmall rounded primary class="tpl-btn-path"
data-trigger="onPathSearch" icon="icon-folder">
<i class="icon-folder"></i>
</button>
</div>
</div>
</div>
<div class="cols-2 left fields">
<div class="cols-1 left">
<menu small eictab vertical>
<li data-trigger="buildToolButtons" data-value="subject" title="Subject line - Mandatory for email"><label>Subject</label></li>
<li data-trigger="buildToolButtons" data-value="header"><label>Header</label></li>
<li data-trigger="buildToolButtons" data-value="main"><label>Main</label></li>
<li data-trigger="buildToolButtons" data-value="footer"><label>Footer</label></li>
<li data-trigger="buildToolButtons" data-value="altText" title="Plain-text version for email clients that don't support HTML"><label>Text (Alt.)</label></li>
<li data-trigger="buildToolButtons" data-value="preview" class="icon-preview" title="Preview the full template (header + main + footer)"><label>Preview</label></li>
</menu>
</div>
<div class="tpl-edit-area">
<div class="eicui-input-container">
<div class="tpl-loadingTools">Tools</div>
<menu small eictab horizontal class="tpl-toolButtons"></menu>
<div class="tpl-history-wrapper">
<button eicbutton xsmall class="toggle-history icon-history" title="Template history"></button>
<div class="tpl-history collapsed"></div>
</div>
</div>
<div class="template-field subject">
<label>Subject is required for sending mail</label>
<div class="content" contenteditable="true" aria-placeholder=""></div>
</div>
<div class="template-field header">
<label>Header</label>
<div eicrichtext class="content" contenteditable="true"></div>
</div>
<div class="template-field main">
<label>Main</label>
<div eicrichtext class="content" contenteditable="true"></div>
</div>
<div class="template-field footer">
<label>Footer</label>
<div eicrichtext class="content" contenteditable="true"></div>
</div>
<div class="template-field altText">
<label>Text (Alt.)</label>
<div eicrichtext class="content" contenteditable="true"></div>
</div>
<div class="template-field preview" contenteditable="false">
<label> Preview</label>
<div eicrichtext class="content" contenteditable="false"></div>
</div>
</div>
</section>
<footer>
<div class="cols-1 right">
<div class="tplActions">
<button eicbutton small primary class="tool-button he-uplImg hidden"
data-trigger="onUploadImage" title="Upload your own image to insert into your template">
<span>Upload image</span>
</button>
<button eicbutton small primary class="tpl-save hidden" data-trigger="saveHtml">
<span>Save</span>
</button>
<button eicbutton small primary class="tpl-sendTest hidden" data-trigger="onTestSend">
<span>Test mail</span>
</button>
<button eicbutton small primary class="tpl-request hidden" data-trigger="onRequestPub">
<span>Request publishing</span>
</button>
<button eicbutton small primary class="tpl-approved hidden" data-trigger="onApproved">
<span>Approve Template</span>
</button>
<button eicbutton small primary class="tpl-rejected hidden" data-trigger="onRejected">
<span>Reject Template</span>
</button>
</div>
</div>
</footer>
</article>
</div>
</section>
</article>
@@ -0,0 +1,788 @@
class TemplatesEditorView extends EICDomContent {
constructor(privileges) {
super('/templates', privileges)
Object.assign(this, app.helpers.activeAttributes)
Object.assign(this, app.helpers.basicDialogs)
}
DOMContentLoaded(options) {
for (let model in options.models) this[model] = options.models[model]
this.components = ui.eicfy(this.el)
this.templateId = options.id
this.decodedHtml = null;
this.decodedAltText = null;
(new Tab()).addTabs(this.findAll('menu li'), this.findAll('.template-field'));
this.setupRefs(this.components)
this.checkAccessAndLoadTemplate()
}
async checkAccessAndLoadTemplate() {
const tplEditorContainer = this.find('.tplEditor')
try {
const result = await this.templates.getReadableFolders()
this.authorizedPaths = result.authorizedPaths || []
if (this.authorizedPaths.length === 0) {
tplEditorContainer.innerHTML = `
<div style="padding: 2rem; text-align: center;">
<h2 style="color:#b00020;">Access Denied</h2>
<p>You are not authorized to access the template editor.</p>
</div>
`
return
}
this.loadTemplate()
} catch (err) {
console.error("Error checking template privileges:", err)
tplEditorContainer.innerHTML = `
<div style="padding: 2rem; text-align: center;">
<h2 style="color:#b00020;">Error</h2>
<p>Unable to check permissions due to a system error.</p>
</div>
`
}
}
async loadTemplate() {
const fieldSubj = this.find('.template-field.subject .content')
this.showLoading(fieldSubj)
const tplEditorContainer = this.find('.tplEditor')
this.gridHistory = new DataGrid(this.find('.tpl-history'), {
headers: [{ label: 'Date', type: 'markup', sortable: true }],
actions: []
})
try {
const result = await this.templates.getReadableFolders()
this.authorizedPaths = result.authorizedPaths || []
if (this.authorizedPaths.length === 0) {
tplEditorContainer.innerHTML = `
<div style="padding: 2rem; text-align: center;">
<h2 style="color:#b00020;">Access Denied</h2>
<p>You are not authorized to access the template editor.</p>
</div>
`
return
}
const currentTemplate = await this.templates.get(this.templateId)
if (!currentTemplate) {
ui.growl.append('Template not found. Please select a valid template.')
return
}
const templatePath = currentTemplate.path || '/'
const isAuthorized = this.authorizedPaths.some(authPath => templatePath.startsWith(authPath))
if (!isAuthorized) {
tplEditorContainer.innerHTML = `
<div style="padding: 2rem; text-align: center;">
<h2 style="color:#b00020;">Access Denied</h2>
<p>You do not have permission to access this template.</p>
</div>
`
return
}
this.editedTemplate = new TemplatesModel(currentTemplate)
const templateStatus = currentTemplate.status
if (templateStatus === 'submitted' || templateStatus === 'prod') {
const tplFields = this.findAll('.template-field, .tpl-inputMeta')
tplFields.forEach(field => {
if (field.tagName === 'INPUT' || field.tagName === 'TEXTAREA') {
field.disabled = true
} else {
field.setAttribute('contenteditable', 'false')
}
field.classList.add('disabled')
})
}
if (this.templates.hasPrivilege('edit')) {
this.toggleButton('onTestSend', ['draft', 'submitted'].includes(templateStatus))
this.toggleButton('saveHtml', ['draft', 'created'].includes(templateStatus))
this.toggleButton('onRequestPub', templateStatus === 'draft')
this.toggleButton('onUploadImage', ['draft', 'created'].includes(templateStatus))
this.toggleButton('onPathSearch', ['draft', 'created'].includes(templateStatus))
}
if (this.templates.hasPrivilege('approved')) {
this.toggleButton('onApproved', templateStatus === 'submitted')
this.toggleButton('onRejected', templateStatus === 'submitted')
}
this.currentPath = templatePath
this.find('.tplPath').textContent = this.currentPath
this.decodeHtmlBin(currentTemplate.html, currentTemplate.meta)
this.find('.tplName').value = currentTemplate.name || ' '
const currentDate = new Date()
const oneYearLater = new Date()
oneYearLater.setFullYear(currentDate.getFullYear() + 1)
const startDate = currentTemplate.startDate ? new Date(currentTemplate.startDate) : currentDate
const endDate = currentTemplate.endDate ? new Date(currentTemplate.endDate) : oneYearLater
this.find('.tpl-formattedDateStart').value = this.formatDateToDDMMYYYYHHMM(startDate)
this.find('.tpl-formattedDateEnd').value = this.formatDateToDDMMYYYYHHMM(endDate)
const headerContent = this.extractSection(this.decodedHtml, 'header')
const mainContent = this.extractSection(this.decodedHtml, 'main')
const footerContent = this.extractSection(this.decodedHtml, 'footer')
this.tplSubj = this.find('.template-field.subject')
this.tplHeader = this.find('.template-field.header')
this.tplMain = this.find('.template-field.main')
this.tplFooter = this.find('.template-field.footer')
this.tplAltText = this.find('.template-field.altText')
this.tplSubj.innerHTML = this.replaceMustaches(currentTemplate.meta.subject || ' ')
this.tplHeader.innerHTML = this.replaceMustaches(headerContent || ' ')
this.tplMain.innerHTML = this.replaceMustaches(mainContent || ' ')
this.tplFooter.innerHTML = this.replaceMustaches(footerContent || ' ')
this.tplAltText.innerText = this.decodedAltText || ' '
this.updatePreview()
const tplEditor = this.find('.tplEditor')
const images = await this.templates.getImages()
const tokens = await this.templates.getTechDDTokens(currentTemplate)
const tId = this.templateId
this.htmlEditor = new HtmlEditor(tplEditor, this, {
images,
tokens,
templateStatus,
tId,
externalHandlers: {
onTestSend: this.onTestSend.bind(this),
onUploadImage: this.onUploadImage.bind(this, templateStatus),
onRequestPub: async () => {
if (this.isDialogOpen) return
this.isDialogOpen = true
await this.confirmDialog({
title: 'Request review ?',
message: `You're about to request a review for this template.<br>
During the review process, the template will no longer be editable.
<div class="prompts">
<label small>Remarks or comments for the reviewer:</label>
<textarea name="comments" eictextarea maxlen="500" cols="50"/></textarea>
</div>`,
promptsClass: 'prompts',
okLabel: 'Request review',
severity: 'warning',
okSeverity: 'primary',
okPromise: async (data) => {
try {
await this.saveHtml('submitted', data.comments)
await this.loadTemplate()
return true
} finally {
this.isDialogOpen = false
}
}
}).finally(() => {
this.isDialogOpen = false
})
},
onApproved: async () => {
if (this.isDialogOpen) return
this.isDialogOpen = true
await this.confirmDialog({
title: 'Template approval ?',
message: `Approve this template ?<br>
Remarks or comments for the reviewer:
<div class="prompts">
<textarea name="comments" eictextarea maxlen="500" cols="50"/></textarea>
</div>`,
promptsClass: 'prompts',
okLabel: 'Approve',
severity: 'warning',
okSeverity: 'primary',
okPromise: async (data) => {
try {
await this.saveHtml('prod', data.comments)
await this.loadTemplate()
return true
} finally {
this.isDialogOpen = false
}
}
}).finally(() => {
this.isDialogOpen = false
})
},
onRejected: async () => {
if (this.isDialogOpen) return
this.isDialogOpen = true
await this.confirmDialog({
title: 'Template rejection ?',
message: `FeedBack<br>
<div class="prompts">
<textarea name="comments" eictextarea maxlen="500" cols="50"/></textarea>
</div>
`,
promptsClass: 'prompts',
okLabel: 'Reject',
severity: 'warning',
okSeverity: 'primary',
okPromise: async (data) => {
try {
await this.saveHtml('draft', data.comments)
this.loadTemplate()
return true
} finally {
this.isDialogOpen = false
}
}
}).finally(() => {
this.isDialogOpen = false
})
},
onPathSearch: this.onPathSearch.bind(this),
onStartDateClicked: this.onStartDateClicked.bind(this),
onEndDateClicked: this.onEndDateClicked.bind(this),
saveHtml: this.saveHtml.bind(this, templateStatus)
}
})
// Re-attach tab listeners after reload
if (typeof Tab === 'function') {
(new Tab()).addTabs(this.findAll('menu li'), this.findAll('.template-field'))
}
// Restore the last active tab if available (with a short delay to ensure listeners are attached)
if (typeof this._lastActiveTabIndex === 'number' && this._lastActiveTabIndex >= 0) {
const tabs = Array.from(this.findAll('menu li'))
if (tabs[this._lastActiveTabIndex]) {
setTimeout(() => {
tabs[this._lastActiveTabIndex].click()
// Force toolbar rebuild for the restored tab
const fieldTypes = ['subject', 'header', 'main', 'footer', 'altText', 'preview']
const fieldType = fieldTypes[this._lastActiveTabIndex] || 'subject'
if (this.htmlEditor && typeof this.htmlEditor.buildToolButtons === 'function') {
this.htmlEditor.buildToolButtons(fieldType)
}
}, 150)
}
}
this.gridHistory.clear()
const history = currentTemplate.statusHistory.sort((a, b) => new Date(b.dateTime) - new Date(a.dateTime))
history.forEach(item => {
const statusColor = {
draft: 'info',
created: 'secondary',
submitted: 'warning',
archived: 'primary',
prod: 'success'
}[item.value] || 'danger'
const oldestDate = new Date(item.dateTime).toLocaleDateString('fr-FR')
const fullName = `<span xsmall class="tpl-fullName">By ${item.changedBy.firstname} ${item.changedBy.lastname}</span>`
const status = `<span class="tpl-status" ${statusColor}>${item.value}</span>`
const comment = item.changedBy.comment?.trim()
? `<span xsmall info class="tpl-comment"><u>comment:</u> ${item.changedBy.comment}</span>`
: ''
const details = `
<div eiccard collapsable collapsed>
<span xsmall class="tpl-details">${oldestDate} - ${status}</span>
<div class="collapsible-content">
${fullName}
${comment}
</div>
</div>
`
const row = this.gridHistory.addRow(item.id, [details])
row.addEventListener('click', () => {
const content = row.querySelector('.collapsible-content')
content.style.display = content.style.display === 'none' || content.style.display === '' ? 'block' : 'none'
})
})
} catch (error) {
console.error("Error loading template:", error)
tplEditorContainer.innerHTML = `
<div style="padding: 2rem; text-align: center;">
<h2 style="color:#b00020;">Error</h2>
<p>Unable to load the template due to a system error.</p>
</div>
`
} finally {
this.hideLoading(fieldSubj)
}
const toggleBtn = document.querySelector('.toggle-history')
const historyPanel = document.querySelector('.tpl-history')
toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
historyPanel.classList.toggle('collapsed')
if (historyPanel.classList.contains('collapsed')) {
toggleBtn.classList.remove('icon-close')
toggleBtn.classList.add('icon-history')
} else {
toggleBtn.classList.remove('icon-history')
toggleBtn.classList.add('icon-close')
}
})
document.addEventListener('click', (e) => {
if (!historyPanel.contains(e.target) && !toggleBtn.contains(e.target)) {
historyPanel.classList.add('collapsed')
toggleBtn.classList.remove('icon-close')
toggleBtn.classList.add('icon-history')
}
})
}
updatePreview() {
if (!this.tplHeader || !this.tplMain || !this.tplFooter) return
const previewContent = `
<div class="tpl-preview-block">${this.tplHeader.innerHTML}</div>
<div class="tpl-preview-block">${this.tplMain.innerHTML}</div>
<div class="tpl-preview-block">${this.tplFooter.innerHTML}</div>
`
this.find('.template-field.preview .content').innerHTML = previewContent
}
onStartDateClicked() {
const startDateInput = this.find('.tplStartDate');
startDateInput.showPicker();
this.syncDateInputs(this.find('.tpl-formattedDateStart'), this.find('.tplStartDate'));
}
onEndDateClicked() {
const endDateInput = this.find('.tplEndDate');
endDateInput.showPicker();
this.syncDateInputs(this.find('.tpl-formattedDateEnd'), this.find('.tplEndDate'));
}
syncDateInputs(textInput, dateInput) {
dateInput.addEventListener('input', () => {
textInput.value = this.formatDateToDDMMYYYYHHMM(dateInput.value);
});
textInput.addEventListener('click', () => {
dateInput.showPicker();
});
}
formatDateToDDMMYYYYHHMM(dateString) {
const date = new Date(dateString);
const dateFormatter = new Intl.DateTimeFormat('fr-FR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false // 24h format
});
const formattedDate = dateFormatter.format(date);
return formattedDate;
}
reformatDateToISO(dateInput) {
let dateObj;
if (typeof dateInput === 'string') {
const parts = dateInput.split(/[\s/:]/);
if (parts.length >= 5) {
const [day, month, year, hours, minutes] = parts;
dateObj = new Date(`${year}-${month}-${day}T${hours}:${minutes}:00`);
} else {
console.error('Invalid date format:', dateInput);
return '';
}
} else if (dateInput instanceof Date) {
dateObj = dateInput;
} else {
console.error('Expected a Date object or valid date string but received:', dateInput);
return '';
}
if (isNaN(dateObj.getTime())) {
console.error('Invalid Date object:', dateObj);
return '';
}
const day = String(dateObj.getDate()).padStart(2, '0')
const month = String(dateObj.getMonth() + 1).padStart(2, '0')
const year = dateObj.getFullYear()
const hours = String(dateObj.getHours()).padStart(2, '0')
const minutes = String(dateObj.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day}T${hours}:${minutes}:00.000Z`
}
toggleButton(trigger, isVisible) {
const button = this.components.find(c => c.el.dataset.trigger === trigger)
if (button) {
requestAnimationFrame(() => {
button.el.classList.toggle('hidden', !isVisible)
})
}
}
async onTestSend() {
const button = this.find('.tpl-sendTest')
// Deactivate button and add spinner
this.showLoading(button)
try {
let result = await this.openDialog(
await this.loadContent(
'comms/templates/dialogs/TemplatesMailTestDialog',
{ title: `Send a test mail...` }
)
)
if (result) {
await this.templates.testMail(this.templateId, result.email)
ui.growl.append('Test mail sent successfully', 'success')
}
}
catch (error) {
console.error("Error on dialog UploadImage loading :", error)
ui.growl.append('Error sending test mail', 'error')
} finally {
// Reactivate button
this.hideLoading(button, '<span>Test mail</span>')
}
}
async onUploadImage(templateStatus) {
const button = this.find('.he-uplImg');
const tabs = Array.from(this.findAll('menu li'))
const activeTabIndex = tabs.findIndex(tab => tab.classList.contains('active'))
this._lastActiveTabIndex = activeTabIndex
this.showLoading(button)
let dialogInstance = null
try {
dialogInstance = await this.openDialog(
await this.loadContent(
'comms/templates/dialogs/TemplatesUplImgDialog',
{ title: 'Upload your own image...' }
)
)
if (!dialogInstance) {
return
}
if (dialogInstance.showDialogSpinner) dialogInstance.showDialogSpinner(true)
const response = await this.templates.saveImage(dialogInstance)
if (response?.success) {
ui.growl.append('Image saved successfully', 'success')
await this.saveHtml(templateStatus)
} else {
ui.growl.append('Error saving image', 'error')
}
} catch (error) {
console.error("Error on dialog UploadImage loading:", error)
} finally {
// Always hide the spinner and close dialog if needed
if (dialogInstance?.showDialogSpinner) {
dialogInstance.showDialogSpinner(false)
}
if (dialogInstance?.cancel) {
dialogInstance.cancel()
}
this.hideLoading(button, '<span>Upload image</span>')
}
}
// Decod html binary64 fron DB
decodeHtmlBin(html, meta) {
this.decodedHtml = html ? this.decodeBase64ToUTF8(html) : '';
this.decodedAltText = meta.altText ? this.decodeBase64ToUTF8(meta.altText) : ''
}
decodeBase64ToUTF8(base64Str) {
const binaryString = atob(base64Str)
const uint8Array = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
uint8Array[i] = binaryString.charCodeAt(i)
}
const decoder = new TextDecoder('utf-8')
return decoder.decode(uint8Array)
}
async onPathSearch() {
const button = this.find('.tpl-btn-path')
this.showLoading(button)
try {
let result = await this.templates.getReadableFolders()
if (!result || typeof result !== 'object') {
console.error("getReadableFolders returned invalid result", result)
return
}
let { templates, authorizedPaths, foldersWithFiles, rights } = result
if (!Array.isArray(authorizedPaths)) authorizedPaths = []
if (!authorizedPaths.includes('/templates')) authorizedPaths.unshift('/templates')
this.templates.ffs.authorizedPaths = authorizedPaths
this.templates.ffs.foldersWithFiles = foldersWithFiles || []
this.templates.ffs.rights = rights || []
if (!templates?.templates) {
templates = { templates }
}
const fileList = Array.isArray(templates.templates) ? templates.templates : []
this.templates.ffs.loadStructure(templates || {}, fileList)
this.templates.ffs.currentPath = '/templates'
this.templates.ffs.authorizedPaths = authorizedPaths
this.currentPath = this.find('.tplPath').textContent
let dialogResult = await this.openDialog(
await this.loadContent(
'templates/Ffs/dialogs/FileBrowserDialog',
{ title: `Select a path...` },
{
ffs: this.templates.ffs,
editable: false,
startPath: this.currentPath,
canManage: true,
templates: this.templates,
context: 'templates'
}
)
)
if (dialogResult) {
this.editedTemplate.path = dialogResult
this.find('.tplPath').textContent = dialogResult
}
} catch (error) {
console.error("Error on dialog PathSearch loading :", error)
} finally {
// Reactivate button
this.hideLoading(button)
}
}
extractSection(html, section) {
const regex = new RegExp(`<${section}.*?>([\\s\\S]*?)<\\/${section}>`, 'i')
const match = html.match(regex)
const extractedText = match ? match[1].trim() : ''
const normStr = this.normalizeText(extractedText)
return normStr
}
//Normalize special char. from Word copy/paste
normalizeText(str) {
return str
.replace(/[\u2018\u2019]/g, "'") //Replace by '
.replace(/[\u201C\u201D]/g, '"') // Replace “ ” by "
.replace(/\u00A0/g, ' ') // Replace space
}
toBase64UTF8(str) {
const encoder = new TextEncoder()
const uint8Array = encoder.encode(str)
let binaryString = ''
uint8Array.forEach(byte => {
binaryString += String.fromCharCode(byte)
});
return btoa(binaryString)
}
fromBase64UTF8(base64) {
const binaryString = atob(base64)
const uint8Array = new Uint8Array([...binaryString].map(char => char.charCodeAt(0)))
const decoder = new TextDecoder()
return decoder.decode(uint8Array)
}
async saveHtml(tplStatus, comment = null) {
const button = this.find('.tpl-save')
// Deactivate button and add spinner
this.showLoading(button)
// Save the current active tab index before reload
const tabs = Array.from(this.findAll('menu li'))
const activeTabIndex = tabs.findIndex(tab => tab.classList.contains('active'))
this._lastActiveTabIndex = activeTabIndex
try {
const newTplStatus = (tplStatus === 'created') ? 'draft' : tplStatus
const tplName = this.find('.tplName').value
const tplPath = this.find('.tplPath').textContent
const tplStartDate = this.reformatDateToISO(this.find('.tpl-formattedDateStart').value)
const tplEndDate = this.reformatDateToISO(this.find('.tpl-formattedDateEnd').value)
const curYear = new Date().getFullYear()
const tplType = 'mail'
const path = (tplPath ? (tplPath.startsWith('/') ? tplPath : '/' + tplPath) : '/Common/' + curYear)
// keep all inputs content
const tplSubject = this.cleanInvisibleChars(this.restoreMustaches(this.find('.template-field.subject').innerHTML))
const headerContent = this.cleanInvisibleChars(this.restoreMustaches(this.find('.template-field.header').innerHTML))
const mainContent = this.cleanInvisibleChars(this.restoreMustaches(this.find('.template-field.main').innerHTML))
const footerContent = this.cleanInvisibleChars(this.restoreMustaches(this.find('.template-field.footer').innerHTML))
const altTextContent = this.toBase64UTF8(this.find('.template-field.altText').innerText)
const newHtml = `<header>${headerContent}</header>
<main>${mainContent}</main>
<footer>${footerContent}</footer>`
this.updatePreview()
const refImgIds = this.extractImageIds(newHtml)
const data = {
id: this.templateId,
type: 'templateInfo',
name: tplName,
path: path,
html: this.toBase64UTF8(newHtml),
status: newTplStatus,
meta: {
subject: tplSubject,
type: tplType,
altText: altTextContent,
},
statusHistory: {
changedBy: {
euLoginId: '',
mail: '',
role: '',
comment: comment || '',
},
dateTime: '',
value: newTplStatus
},
startDate: tplStartDate,
endDate: tplEndDate,
imgRefs: refImgIds || '',
comment: comment || '',
}
const response = await this.editedTemplate.save(data)
if (response.success) {
ui.growl.append('Template saved successfully', 'success')
} else {
ui.growl.append('Error saving template', 'error')
}
} catch (error) {
console.error("Error saving template :", error)
} finally {
// Reactivate button
this.hideLoading(button, '<span>Save</span>')
}
}
cleanInvisibleChars(str) {
return str
.replace(/\u200B/g, '') // Unicode invisibles chars
.replace(/&ZeroWidthSpace;/gi, '') // HTML entity for zero-width space
.normalize('NFC') // Normalisation Unicode
.replace(/[\u200B-\u200D\uFEFF]/g, '') // zero-width & BOM
.replace(/&ZeroWidthSpace;/gi, '') // HTML entity
.replace(/[\u2018\u2019\u02BC]/g, "'") // smart apostrophes → '
.replace(/[\u201C\u201D\u00AB\u00BB]/g, '"') // smart quotes → "
.replace(/[\u2013\u2014\u2212]/g, '-') // dashes → -
.replace(/\u2026/g, '...') // ellipsis → ...
.replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g, ' ') // weird spaces → normal space
.replace(/[\u0000-\u001F\u007F]/g, '') // control chars
.replace(/\s{2,}/g, ' ') // double spaces
.replace(/[ \t]+\n/g, '\n') // strip trailing space before newlines
.replace(/\n{3,}/g, '\n\n') // max 2 line breaks
.trim()
}
extractImageIds(htmlContent) {
const tempElement = document.createElement('div')
tempElement.innerHTML = htmlContent
const imgElements = tempElement.querySelectorAll('img')
const ids = [];
imgElements.forEach(img => {
if (img.id) {
ids.push(img.id)
}
})
return ids.join(',')
}
async onDecision(status) {
if (status === 'prod') {
let result = await this.confirmDialog({
title: 'APPROVE this template ?',
message: `<p>This approval will be final.</p>
<p>Are you sure?</p>`,
okLabel: 'Approve',
severity: 'warning',
okPromise: () => this.saveHtml(status, '')
})
if (result) {
ui.growl.append("Template approved !", 'success')
} else {
ui.growl.append("Error approval!", 'error')
}
} else {
let result = await this.openDialog(
await this.loadContent(
'comms/templates/dialogs/TemplatesDecisionDialog',
{ title: `Comment your decision...` }
)
)
if (result) {
await this.saveHtml(status, result.comment)
}
}
}
async addImage(data) {
const response = await this.editedTemplate.saveImage(data)
if (response.success) {
ui.growl.append('Image saved successfully', 'success')
this.loadTemplate(this.templateId)
} else {
ui.growl.append('Error saving image', 'error')
}
}
//Manage tokens mustaches
replaceMustaches(html) {
return html.replace(/\{\{(.*?)\}\}/g, function (match, p1) {
return `<span class="tpl-token" eicchip="" success="" data-original="${p1.trim()}">${p1.trim()}</span>`
});
}
restoreMustaches(html) {
return html.replace(/<span class="tpl-token"[^>]*data-original="(.*?)"[^>]*>.*?<\/span>/g, '{{$1}}')
}
}
app.registerClass('TemplatesEditorView', TemplatesEditorView);
@@ -0,0 +1,192 @@
<style>
.templitor > header { background: url('/app/assets/images/cards/templitorHeader.png') center/cover no-repeat; }
.templitor .search-bar{
grid-template-columns: auto min-content;
align-items: baseline;
}
.templitor .library .row {
display: grid;
grid-template-columns: auto 120px 120px;
}
.templitor .library .dataset .row:hover {
background-color: var(--eicui-base-color-grey-5) !important;
box-shadow:
inset 0 2px 4px rgba(0, 0, 0, 0.2),
inset 0 -1px 0 rgba(255, 255, 255, 0.4) !important;
transform: translateY(2px) !important;
}
.templitor .library .dataset .row.selected {
background-color: var(--eicui-base-color-grey-5) !important;
border-left: 5px solid var(--eicui-base-color-info);
box-shadow:
inset 0 2px 4px rgba(0, 0, 0, 0.2),
inset 0 -1px 0 rgba(255, 255, 255, 0.4) !important;
transform: translateY(2px) !important;
}
.templitor .library .dataset .row .cell:nth-child(3),
.templitor .library .dataset .row .cell:nth-child(4) {
text-align: center;
}
.templitor .library.thumbs header,
.templitor .library.thumbs footer { display:none; }
.templitor .library .dataset { max-height: 50vh; }
.templitor .library.thumbs .dataset { max-height: 55vh; }
.templitor .library.thumbs .dataset .row {
display: inline-grid;
width: 320px;
grid-template-columns: none;
border-left: 4px solid var(--app-color-info);
margin: var(--eicui-base-spacing-xs);
background: var(--eicui-base-color-white);
padding: var(--eicui-base-spacing-2xs);
box-shadow: 3px 3px var(--eicui-base-color-grey-20);
border-top: 1px solid var(--eicui-base-color-grey-20);
}
.templitor .tplInfos{
display: inline-block;
}
.templitor .library.thumbs .dataset .row .cell:nth-child(2) {
font-weight: bold;
grid-column: 1 / 3;
}
.templitor .library.thumbs .dataset .row .cell:nth-child(3) { grid-row: 2; grid-column: 1; text-align:left; }
.templitor .library.thumbs .dataset .row .cell:nth-child(4) {
grid-row: 2;
grid-column: 2;
text-align: right;
}
.templitor .tpl-createButton{
float: right;
}
.templitor .tpl-filters {
padding: var(--eicui-base-spacing-s) 0;
display: grid;
grid-auto-flow: column;
grid-gap: var(--eicui-base-spacing-m);
width: fit-content;
}
.templitor .preview {
width: 40vw;
transition: all 0.6s ;
opacity: 1;
}
.templitor .preview[disabled] { width: 0; opacity:0; }
.templitor .preview .html {
border: 1px solid #ccc;
padding: 10px;
border-radius: 5px;
position: inherit;
overflow: auto;
max-height: 50vh;
}
.templitor .preview .html img { max-width: 100%; height: auto; }
.templitor .preview .html table { table-layout: auto; }
.templitor .tpl-path{
display: inline-block;
padding: 0.2em 0.6em;
border-radius: 1em;
/*background-color: var(--eicui-base-color-accent-10);
color: var(--eicui-base-color-black);
*/
background-color: white !important;
color: white !important;
font-size: 0.75rem;
white-space: nowrap;
max-width: calc(100vw * 0.6);
overflow: hidden;
text-overflow: ellipsis;
}
.templitor .preview .html.not-editable::after {
content: 'Not Editable';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-45deg);
font-size: 5rem;
color: rgba(200, 0, 0, 0.1); /* Transparent red */
z-index: 10;
pointer-events: none;
white-space: nowrap;
}
.templitor button[eicuser] span[eicbadge] {
visibility: hidden;
}
.templitor button.loading {
pointer-events: none;
opacity: 0.7;
}
.icon-spinner {
display: inline-block;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.templitor .error-message {
color: red;
padding: 1em;
font-weight: bold;
text-align: center;
}
.templitor .empty-message {
color: #999;
padding: 1em;
text-align: center;
}
.templitor .tpl-filters {
padding: var(--eicui-base-spacing-s) 0;
display: grid;
grid-auto-flow: column;
grid-gap: var(--eicui-base-spacing-m);
width: fit-content;
}
</style>
<article eiccard media class="templitor">
<header>
<h1>Templates Manager</h1>
<h2>Documents & Templates</h2>
</header>
<section>
<article eiccard>
<section>
<div class="cols-2 search-bar">
<select eicselect data-ref="searchCriteria" data-change="onSearchChange" editable data-type="array" placeholder="Search on title, path or user"></select>
<button eicbutton xsmall rounded primary class="tpl-btn-path" data-trigger="onPathBrowse" icon="icon-folder"><i class="icon-folder"></i></button>
</div>
</section>
<section>
<div class="cols-2 right" >
<div class="tpl-filters"></div>
<button class="tpl-createButton" eicbutton small info data-trigger="onTemplateCreate"><span>Create</span></button>
</div>
<div class="cols-2 right">
<div class="library thumbs">
<div class="cols-2 right">
<div eicdatagrid class="templatesList" data-output="searchResults" data-async="results" ></div>
<div class="cols-2">
<button eicbutton rounded small primary data-value="grid" data-trigger="onTemplatesDisplay" icon="icon-list-ul"></button>
<button eicbutton rounded small primary data-value="thumbs" data-trigger="onTemplatesDisplay" icon="icon-table"></button>
</div>
</div>
</div>
<article eiccard class="preview" disabled>
<header>
<div class="cols-2 right">
<div>
<h1>Select a template in the library</h1>
<h2>Template preview</h2>
</div>
<button eicbutton xsmall rounded secondary data-trigger="onPreviewClose" icon="icon-cancel"></button>
</div>
</header>
<section><div class="html"></div></section>
</article>
</div>
</section>
</article>
</section>
</article>
@@ -0,0 +1,561 @@
class TemplatesManagerView extends EICDomContent {
constructor(privileges) {
super('/templates', privileges)
Object.assign(this, app.helpers.basicDialogs)
Object.assign(this, app.helpers.activeAttributes)
Object.assign(this, app.helpers.validators)
this.selectedTemplate = null
this.decodedHtml = null
this.decodedAltText = null
}
DOMContentLoaded(options) {
for (let model in options.models) this[model] = options.models[model]
this.components = ui.eicfy(this.el)
this.gridTemplates = new DataGrid(this.find('.library [eicdatagrid]'), {
headers: [
{ label: 'Name', filter: 'text', sortable: true },
{ label: 'Author', type: 'markup', filter: 'text', sortable: true },
{ label: 'Delete', type: 'button' },
{ label: 'Status', type: 'markup' },
{ label: 'Actions', type: 'markup' },
],
actions: []
})
this.gridTemplates.onRowClick = (row, event) => {
const target = event.target
const isButton = target.closest('.template-actions button')
if (!isButton) {
this.onTemplateSelect(row)
}
}
this.preview = new Card(this.find('.preview'))
this.previewContent = this.find('.preview .html').attachShadow({ mode: 'open' })
this.setupTriggers(this.components)
this.setupRefs(this.components)
this.setupStatusButtons()
this.components.searchCriteria.value = [app.User.identity.uuid]
}
async refreshTpl() {
this.gridTemplates.loading = true
try {
const result = await this.templates.getReadableFolders()
this.authorizedPaths = result.authorizedPaths || []
if (this.authorizedPaths.length === 0) {
this.output('searchResults', `<div style="padding: 2rem; text-align: center;">
<h2 style="color:#b00020;">Access Denied</h2>
<p>You're not authorized to access the templates dashboard</p>
</div>`)
return
}
const tpls = await this.templates.search(
this.components.searchCriteria.value,
this.getSelectedStatus()
)
if (tpls?.message === 'NO_ACCESS') {
this.output('searchResults', `<div style="padding: 2rem; text-align: center;">
<h2 style="color:#b00020;">Access Denied</h2>
<p>You're not authorized to view templates</p>
</div>`)
return
}
this.fillTemplates(tpls)
} catch (error) {
this.output('searchResults', `<div style="padding: 2rem; text-align: center;">
<h2 style="color:#b00020;">Unexpected Error</h2>
<p>An error occurred while loading templates. Please try again later</p>
</div>`)
console.error(error)
} finally {
this.gridTemplates.loading = false
}
}
onSearchChange(component, event) {
event.stopPropagation()
event.preventDefault()
this.refreshTpl()
}
setupStatusButtons() {
this.statusButtons = {}
for (let status in this.templates.getStatusLabel()) {
this.statusButtons[status] = new Button(null, { rounded: true, size: 'xsmall', severity: 'info', label: `<span>${this.templates.getStatusLabel()[status]}</span>` })
this.statusButtons[status].click = this.onStatusChange.bind(this, status)
this.find('.tpl-filters').append(this.statusButtons[status].el)
}
}
onStatusChange(status, event) {
event.stopPropagation()
event.preventDefault()
this.statusButtons[status].severity =
this.statusButtons[status].severity === 'info' ? 'secondary' : 'info'
this.refreshTpl()
}
getSelectedStatus() {
let selected = Object.keys(this.statusButtons).filter(status =>
this.statusButtons[status].severity === 'info'
)
return selected.length > 0 ? selected : Object.keys(this.statusButtons)
}
async onPathBrowse() {
const button = this.find('.tpl-btn-path')
this.showLoading(button)
let result = await this.templates.getReadableFolders()
let { templates, authorizedPaths } = result
if (!Array.isArray(authorizedPaths)) {
authorizedPaths = []
}
if (!authorizedPaths.includes('/templates')) authorizedPaths.unshift('/templates')
this.templates.ffs.authorizedPaths = authorizedPaths
if (!templates.templates) {
templates = { templates }
}
this.templates.ffs.loadStructure(templates || {}, [])
this.templates.ffs.currentPath = '/templates'
this.templates.ffs.authorizedPaths = authorizedPaths
let dialogResult = await this.openDialog(
await this.loadContent(
'templates/Ffs/dialogs/FileBrowserDialog',
{ title: `Select a path...` },
{
ffs: this.templates.ffs,
templates: this.templates.templates,
editable: false,
canManage: false,
startPath: '/templates',
}
)
)
if (dialogResult) {
const criteria = this.components.searchCriteria.value.filter(item => (!item.startsWith('/')))
this.components.searchCriteria.value = [...criteria, dialogResult]
}
this.hideLoading(button)
}
setupStatusButtons() {
this.statusButtons = {}
this.statusCounters = {}
for (let status in this.templates.getStatusLabel()) {
const label = this.templates.getStatusLabel()[status]
// Crée le bouton avec label
const button = new Button(null, {
rounded: true,
size: 'xsmall',
severity: 'info',
label: `<span>${label}</span>`
})
// Create the badge for the count
const badge = new Badge(null, { size: 'xxsmall' })
badge.value = '0'
button.el.append(badge.el)
// Store references to the button and badge
this.statusButtons[status] = button
this.statusCounters[status] = badge
this.statusButtons[status].click = this.onStatusChange.bind(this, status)
this.find('.tpl-filters').append(button.el)
}
}
updateStatusCounts(counts) {
for (let status in this.templates.getStatusLabel()) {
const count = counts[status] || 0
if (this.statusCounters[status]) {
this.statusCounters[status].value = count.toString()
}
}
}
fillTemplates(list) {
this.gridTemplates.clear()
this.gridTemplates.loading = true
let statusCounts = {}
for (let item of list) {
statusCounts[item.status] = (statusCounts[item.status] || 0) + 1
let statusBadge
switch (item.status) {
case 'draft': statusBadge = 'info'; break
case 'created': statusBadge = 'secondary'; break
case 'submitted': statusBadge = 'warning'; break
case 'archived': statusBadge = 'primary'; break
case 'prod': statusBadge = 'success'; break
default: statusBadge = 'danger'
}
let changeBy = ''
let oldest = item.statusHistory.find(() => true)
if (oldest) changeBy = oldest.changedBy
let oldestDate = new Date(oldest.dateTime).toLocaleDateString('fr-FR')
let fullName = changeBy.firstname + ' ' + changeBy.lastname
let statusLabel = item.status === 'prod' ? 'approved' : item.status
const dirs = item.path.split('/').filter(item => item)
const pathChips = dirs.map((dir, idx) => {
const path = dirs.slice(0, idx + 1).join('/')
return (`<span eicchip xxsmall info data-path="/${path}">${dir}</span>`)
}).join('')
let tplInfos = `<span eicchip ${statusBadge} xsmall outline><label>${statusLabel}</label></span>`
let tplTitle = `<div>${item.name}<br/><span xsmall class-"tplInfos">last action: ${oldestDate}<br/>by: ${fullName}</span></div>`
let deleteB = ''
if (item.status === 'created' || item.status === 'draft') {
deleteB = `<div><button eicbutton class="icon-bin delete-tpl" xsmall rounded danger type="button" data-id="${item.id}" data-trigger="onDeleteTemplate"></button><div>`
}
let tplActions = `
<div class="template-actions">
${
item.status !== 'prod'
? `<button eicbutton class="open-btn icon-pen" xsmall rounded primary data-id="${item.id}" type="button" data-trigger="onTemplateEdit" title="Edit this template"></button>`
: ''
}
<button eicbutton class="preview-btn icon-copy" xsmall rounded data-id="${item.id}" type="button" data-trigger="onTemplateClone" title="Clone this template"></button>
</div>
`
let row = this.gridTemplates.addRow(item.id, [pathChips, tplTitle, tplInfos, deleteB, tplActions])
row.dataset.type = 'file'
let previewButton = row.querySelector('[data-trigger="onTemplateSelect"]')
if (previewButton) {
previewButton.addEventListener('click', (event) => {
event.stopPropagation()
this.onTemplateSelect(row)
})
}
let editButton = row.querySelector('[data-trigger="onTemplateEdit"]')
if (editButton) {
editButton.addEventListener('click', (event) => {
event.stopPropagation()
this.onTemplateEdit(item.id)
})
}
let cloneButton = row.querySelector('[data-trigger="onTemplateClone"]')
if (cloneButton) {
cloneButton.addEventListener('click', (event) => {
event.stopPropagation()
this.onTemplateClone(item.id, event.currentTarget)
})
}
let deleteButton = row.querySelector('[data-trigger="onDeleteTemplate"]')
if (deleteButton) {
deleteButton.addEventListener('click', (event) => {
event.stopPropagation()
this.onDeleteTemplate(item.id, item.name)
})
}
}
this.updateStatusCounts(statusCounts)
this.gridTemplates.loading = false
}
async onTemplateSelect(row) {
const currentId = row.dataset.id
if (row.dataset.type == 'file') {
this.find('.preview').removeAttribute('disabled')
this.preview.loading = true
let data = await this.templates.get(currentId)
if (data) {
let rawHtml = data.html || ''
let html = this.replaceMustaches(this.decodeBase64Utf8(rawHtml))
html = html.replace(/[\u200B-\u200D\uFEFF]/g, '')
html = html
.replace(/\u200B/g, '')
.replace(/&ZeroWidthSpace;/gi, '')
this.previewContent.innerHTML = `<style>
span.tpl-token[eicchip] {
border-radius: 60px !important
background-color: var(--eicui-base-color-success-100)
color: var(--eicui-base-color-white)
font-size: 0.75rem
padding: var(--eicui-base-spacing-2xs) var(--eicui-base-spacing-s)
min-height: var(--eicui-base-spacing-xl)
display: inline-grid
grid-template-columns: min-content min-content min-content
min-height: 0
align-items: center
margin: 1px var(--eicui-base-spacing-2xs)
pointer-events: all
white-space: nowrap
position: relative
text-shadow: none
}
</style>\n${html}`
const images = this.previewContent.querySelectorAll('img')
images.forEach((img) => {
img.style.maxWidth = '100%'
img.style.height = 'auto'
})
this.findAll('.templatesList .row.selected').forEach(r => r.classList.remove('selected'))
row.classList.add('selected')
this.find('.preview').removeAttribute('disabled')
this.selectedTemplate = data
row.scrollIntoView({ behavior: 'smooth', block: 'start' })
} else {
this.find('.preview').removeAttribute('disabled')
ui.growl.append('Template not found', 'danger')
}
this.preview.loading = false
}
else if (row.dataset.type == 'folder') this.onFolderSelect(currentId)
}
async onDeleteTemplate(tplId, tplName) {
if (!this.templates.hasPrivilege('delete')) {
ui.growl.append("You do not have permissions to delete this template.", 'danger')
return
}
let result = await this.confirmDialog({
title: 'DELETE template ?',
message: `<p>You are about to permanently delete the template "<b>${tplName}</b>".</p>
<p>Are you sure?</p>`,
okLabel: 'Delete',
severity: 'danger',
okPromise: () => this.templates.delete(tplId)
})
if (result) {
ui.growl.append("Template deleted !", 'success')
this.refreshTpl()
} else {
ui.growl.append("Error deleting Template!", 'error')
}
}
onFolderSelect(path) {
this.templates.ffs.changeDir(path)
this.refreshTpl()
}
async onTemplateClone(tId, button) {
if (!button) return
button.classList.add('spin')
button.disabled = true
try {
const originalTpl = await this.templates.get(tId)
if (!originalTpl) {
ui.growl.append('Original template not found', 'danger')
return
}
const responseCreate = await this.templates.save({ status: 'created' })
if (responseCreate.success) {
const newId = responseCreate.payload.id
const responseClone = await this.createAndSaveNewTemplate(newId, originalTpl)
if (responseClone.success) {
app.Router.route(`/templates/${newId}`)
} else {
ui.growl.append('Error cloning template', 'error')
}
} else {
ui.growl.append('Error creating template', 'error')
}
} catch (error) {
console.error('Error cloning template:', error)
ui.growl.append('Error cloning template', 'error')
} finally {
button.classList.remove('spin')
button.disabled = false
}
}
async onTemplateEdit(tId) {
const button = this.find(`[data-id="${tId}"][data-trigger="onTemplateEdit"]`)
if (!button) return
button.classList.add('spin')
button.disabled = true
try {
app.Router.route(`/templates/${tId}`, { mode: 'edit', ffsData: this.templates.ffs })
} catch (error) {
console.error('Error opening template:', error)
ui.growl.append('Error opening template', 'error')
} finally {
button.classList.remove('spin')
button.disabled = false
}
}
/*Manage template record for new template
@status = created
*/
async onTemplateCreate() {
const button = this.find('.tpl-createButton');
if (!button) return
button.disabled = true;
button.classList.add('loading')
button.innerHTML = '<i class="icon-spinner spin"></i> Creating...'
const responseCreate = await this.templates.save({ status: 'created' })
if (responseCreate.success) {
this.newId = responseCreate.payload.id
const templateStatus = 'created'
const tplDate = new Date().toISOString()
const path = this.authorizedPaths?.[0]
const tplName = 'New template'
const html = btoa('<p>new template</p>')
const altText = ''
const subject = 'New template'
const tplData = {
id: this.newId,
type: 'templateInfo',
name: tplName,
path: path,
html: html,
status: templateStatus,
meta: {
subject: subject,
type: '',
altText: altText,
},
statusHistory: {
changedBy: {
euLoginId: app.User.identity.uuid,
mail: app.User.identity.mail,
role: app.User.identity.role,
},
dateTime: tplDate,
value: templateStatus,
},
startDate: tplDate,
endDate: tplDate,
}
const response = await this.templates.save(tplData)
if (response.success) {
app.Router.route(`/templates/${this.newId}`)
button.disabled = false
button.classList.remove('loading')
button.innerHTML = '<span>Create New Template</span>'
}
else {
ui.growl.append('Error creating template', 'error')
}
}
}
/*Manage template record for clone
@tplData
*/
async createAndSaveNewTemplate(newId, tplData = {}) {
const templateStatus = 'draft'
const tplDate = new Date().toISOString()
const path = tplData.path || this.authorizedPaths?.[0]
const tplName = tplData.name || ' template'
const html = tplData.html || btoa('<p>CLONE template</p>')
const altText = tplData.meta?.altText || ''
const subject = tplData.meta?.subject || ''
const data = {
id: newId,
type: 'templateInfo',
name: 'CLONE-' + tplName,
path: path,
html: html,
status: templateStatus,
meta: {
subject: 'CLONE-' + subject,
type: '',
altText: altText,
},
statusHistory: {
changedBy: {
euLoginId: '',
mail: '',
role: '',
},
dateTime: '',
value: templateStatus,
},
startDate: tplDate,
endDate: tplDate,
}
const response = await this.templates.save(data)
if (response.success) {
return { success: true, id: response.payload.recId }
}
else {
ui.growl.append('Error creating template', 'error')
}
}
getEarliestDateTime(statusHistory) {
if (!Array.isArray(statusHistory)) return null
return statusHistory.reduce((earliest, entry) => {
const entryDate = new Date(entry.dateTime)
return !earliest || entryDate < earliest ? entryDate : earliest
}, null)
}
onPreviewClose() {
this.find('.preview').setAttribute('disabled', '')
}
onTemplatesDisplay(event) {
let button = event._el
switch (button.dataset.value) {
case 'thumbs':
this.find('.library').classList.add('thumbs')
break
default:
this.find('.library').classList.remove('thumbs')
}
}
/**
* replaceMustaches : replaces {{mustache}} with a span with class tpl-token
* @param {string} html : the html to process
* @returns {string} : the processed html
*/
replaceMustaches(html) {
return html.replace(/\{\{(.*?)\}\}/g, function (match, p1) {
return `<span class="tpl-token" eicchip="" success="" data-original="${p1.trim()}">${p1.trim()}</span>`
})
}
decodeBase64Utf8(str) {
return decodeURIComponent(
Array.from(atob(str)).map(c =>
'%' + c.charCodeAt(0).toString(16).padStart(2, '0')
).join('')
)
}
}
app.registerClass('TemplatesManagerView', TemplatesManagerView)
+42
View File
@@ -0,0 +1,42 @@
<article eiccard class="fasttracks dashboard">
<header>
<h1>Bypass</h1>
<h2>Global monitoring</h2>
</header>
<section>
<div class="tabs-extended">
<section>
<menu eictab>
<li>Tokens</li>
<li>History</li>
<li class="settings">Settings</li>
</menu>
</section>
<!--
<section>
<button eicbutton rounded basic primary small icon="icon-cog" title="Manage settings"></button>
</section>-->
</div>
<article eiccard class="tokens content">
<section>
<div class="cols-2 right middle">
<div class="metrics">
<div class="assigned-past"><span>0</span><label>assigned <span class="year"></span></label></div>
<div class="assigned-current"><span>0</span><label>assigned <span class="year"></span></label></div>
<div class="consumed-past"><span success>0</span><label success>consumed <span class="year"></span></label></div>
<div class="consumed-current"><span success>0</span><label success>consumed <span class="year"></span></label></div>
</div>
<button eicbutton primary class="grant-token">Grant a token</button>
</div>
<div eicdatagrid class="global-granted-tokens" footer="hidden"></div>
</section>
</article>
<article eiccard class="history content">
<section>
<div eicdatagrid class="history-tokens"></div>
</section>
</article>
<article eiccard class="config content"></article>
</section>
</article>
+365
View File
@@ -0,0 +1,365 @@
class BypassAdminDashboard extends EICDomContent {
DOMContentLoaded(options) {
ui.eicfy(this.el);
this.profile = options.profile;
for(let model in options.models) this[model] = options.models[model];
this.tabs = new Tab();
this.tabs.addTabs(this.findAll('.tabs-extended menu li'), this.findAll('.content'))
this.gridTokens = new DataGrid(this.find('.global-granted-tokens'), {
headers: [
{ label: 'PIC', type: 'markup', filter: 'text', sortable: true},
{ label: 'Company name', filter: 'text', sortable: true},
{ label: 'Token granted',type: 'date', filter: 'text', sortable: true},
{ label: 'Scheme', filter: 'list', sortable: true},
{ label: 'Entity', filter: 'text', sortable: true},
{ label: 'Status', filter: 'list', sortable: true},
{ label: 'Project', filter: 'text', sortable: true},
{ label: 'Short proposal submission', type: 'date', filter: 'text', sortable: true}
],
height: 'calc(100vh - 472px)',
actions: []
});
this.btTokenDl = new Button(null, {icon: 'icon-download', severity: 'secondary', rounded: true, size: 'small', hint: 'Download complete list as CSV'})
this.btTokenDl.addEventListener('click', this.downloadTokensList.bind(this));
this.gridTokens.el.querySelector('header .cell.actions').append(this.btTokenDl.el);
this.btTokenRefresh = new Button(null, {icon: 'icon-refresh', severity: 'primary', rounded: true, size: 'small', hint: 'Refresh the list'})
this.btTokenRefresh.addEventListener('click', this.refreshTokensList.bind(this));
this.gridTokens.el.querySelector('header .cell.actions').append(this.btTokenRefresh.el);
this.gridTokens.onRowFiltered = this.refreshMetrics.bind(this);
this.gridHistory = new DataGrid(this.find('.history-tokens'), {
headers: [
{ label: 'Date', type: 'dateTime', filter: 'text', sortable: true},
{ label: 'PIC', filter: 'text', sortable: true},
{ label: 'Company name', filter: 'text', sortable: true},
{ label: 'Action', filter: 'list', sortable: true},
{ label: 'Managed by', filter: 'text', sortable: true},
],
height: 'calc(100vh - 420px)',
actions: []
});
let btGrant = this.find('.grant-token');
if(this.company.hasPrivilege('grant')) {
btGrant.addEventListener('click', this.onTokenGrant.bind(this));
} else {
ui.hide(btGrant);
}
if(this.tokens.hasPrivilege('list')) { this.refreshTokensList(); }
if(this.users) {
this.loadContent(
'projects/bypass/BypassAdminManagementContent',
{ title: 'Accelerator configuration', subtitle: 'Users access & token management' },
{ profile: this.profile, models: { "users": this.users, "tokens": this.tokens } }
).then(view => this.setupUserManagement(view));
} else {
ui.hide(this.find('li.settings'));
}
}
setupUserManagement(view) {
this.userManagement = view;
this.find('.content.config').append(view.el);
}
fill() {
let year = new Date().getFullYear();
let awardedPast = this.tokens.awardedTokens(year - 1);
awardedPast.forEach(item => item.ref = 'assigned-past');
let awardedCurrent = this.tokens.awardedTokens(year);
awardedCurrent.forEach(item => item.ref = 'assigned-current');
let consumedPast = this.tokens.consumedTokens(year - 1);
consumedPast.forEach(item => item.ref = 'consumed-past');
let consumedCurrent = this.tokens.consumedTokens(year);
consumedCurrent.forEach(item => item.ref = 'consumed-current');
let history = this.tokens.history();
this.find('.metrics .assigned-current .year').innerText = year;
this.find('.metrics .consumed-current .year').innerText = year;
this.find('.metrics .assigned-past .year').innerText = year - 1;
this.find('.metrics .consumed-past .year').innerText = year - 1;
this.gridHistory.loading = false;
this.gridTokens.loading = false;
this.btTokenRefresh.loading = false;
this.gridTokens.clear();
let awarded = awardedCurrent.concat(awardedPast);
awarded.sort((a,b) => a.itemData.granted < b.itemData.granted ? 1: -1);
for (const item of awarded) {
let priority = '';
switch(item.itemData.status) {
case 'consumed':
priority = 'success';
break;
}
let row = this.gridTokens.addRow(item.itemData.id, [
`<a href="#" data-id="${item.itemData.id}" data-pic="${item.itemData.enterprise.pic}" data-legalname="${item.itemData.enterprise.legalname}" title="View token details">${item.itemData.enterprise.pic}</a>`,
item.itemData.enterprise.legalname,
item.itemData.granted,
app.meta.toString('accelerator-tracks', item.itemData.track),
app.meta.toString('accelerator-tracks', item.itemData.domain),
`<span ${priority}>${item.itemData.status}</span>`,
item.itemData.project ? item.itemData.project.acronym: '',
item.itemData.project ? item.itemData.project.submissionDate: ''
], true);
let tokenLink = row.querySelector('.cell:nth-child(2) a')
tokenLink.addEventListener('click', this.onTokenView.bind(this));
row.classList.add(item.ref);
// checking if token can be revoked (meaning status is allocated and user has privilege for that action)
if((item.itemData.status == "allocated") && this.company.hasPrivilege('revoke') && (!item.itemData.isAssignedToShortProposal)) {
let buttonBar = ui.create(`<div class="cols-2"></div>`)
let revokeButton = ui.create(`<button eicbutton xsmall danger title="revoke this token" data-pic="${item.itemData.enterprise.pic}" data-legalname="${item.itemData.enterprise.legalname}" data-track="${item.itemData.track}" data-domain="${item.itemData.domain}" >Revoke</button>`);
revokeButton.addEventListener('click', this.onTokenRevoke.bind(this));
buttonBar.appendChild(revokeButton);
let useButton = ui.create(`<button eicbutton xsmall info title="Activate the coaching" data-id="${item.itemData.id}" data-pic="${item.itemData.enterprise.pic}" data-legalname="${item.itemData.enterprise.legalname}" data-track="${item.itemData.track}" data-domain="${item.itemData.domain}">Consume</button>`);
useButton.addEventListener('click', this.onTokenConsume.bind(this));
buttonBar.appendChild(useButton);
row.querySelector('.cell.actions').appendChild(buttonBar);
} else if((item.itemData.status == "allocated") && this.company.hasPrivilege('revoke') && (item.itemData.isAssignedToShortProposal)){
row.querySelector('.cell.actions').appendChild(ui.create(`<span>Consuming...</span>`));
}
}
this.gridTokens.updateFilters();
// filling the history list
this.gridHistory.clear();
for (const item of history) {
let priority = ''
switch(item.action) {
case 'revoked': priority = 'danger'; break;
case 'consumed': priority = 'success'; break;
}
let row = this.gridHistory.addRow(item.date, [
item.date,
item.pic,
item.legalname,
`<span ${priority}>${item.action}</span>`,
`${item.actor.firstname ? item.actor.firstname[0] + '. ': ''}${item.actor.lastname}`
], true);
}
this.gridHistory.updateFilters();
this.refreshMetrics();
}
refreshMetrics() {
let rows = Array.from(this.gridTokens.filteredRows);
let assignedPast = rows.filter(item => item.classList.contains('assigned-past'));
let assignedCurrent = rows.filter(item => item.classList.contains('assigned-current'));
let consumedPast = rows.filter(item => item.classList.contains('consumed-past'));
let consumedCurrent = rows.filter(item => item.classList.contains('consumed-current'));
this.find('.metrics .assigned-past span').innerText = assignedPast.length;
this.find('.metrics .assigned-current span').innerText = assignedCurrent.length;
this.find('.metrics .consumed-past span').innerText = consumedPast.length;
this.find('.metrics .consumed-current span').innerText = consumedCurrent.length;
}
refreshTokensList(event) {
if(event) {
event.preventDefault();
event.stopPropagation();
}
this.btTokenRefresh.loading = true;
this.gridTokens.loading = true;
this.gridHistory.loading = true;
this.tokens.list().then(this.fill.bind(this));
}
downloadTokensList(event) {
if(event) {
event.preventDefault();
event.stopPropagation();
}
this.btTokenDl.loading = true;
this.tokens.export({filter: { includePersons: true }}).then(this.buildCSV.bind(this));
}
buildCSV(payload) {
let options = {
headers: [
'PIC',
'Organisation Name',
'Granted on',
'Scheme',
'Entity',
'Program',
'Status',
'Project',
'Submission date',
'Contact name',
'Contact gender',
'Contact position',
'Contact email',
'Contact phone',
'SPOC name',
'SPOC email',
],
filename: 'EIC-Bypass',
delimiter: ';'
}
let data = [];
for(let token of payload.tokens) {
let row = [
token.enterprise.pic,
token.enterprise.legalname,
token.granted,
app.meta.toString('accelerator-tracks', token.track),
app.meta.toString('accelerator-tracks', token.domain),
token.program || "",
token.history[0].status,
token.project ? token.project.acronym: '',
token.project ? token.project.submissiondate: '',
token.shortProposal ? token.shortProposal.contact.lastname + ' ' + token.shortProposal.contact.firstname: '',
token.shortProposal ? token.shortProposal.contact.gender: '',
token.shortProposal ? token.shortProposal.contact.position: '',
token.shortProposal ? token.shortProposal.contact.email: '',
token.shortProposal ? token.shortProposal.contact.phone: '',
token.spoc ? token.spoc.lastname + ' ' + token.spoc.firstname: '',
token.spoc ? token.spoc.email: '',
]
data.push(row);
}
app.helpers.translator.toCSV( data, options );
this.btTokenDl.loading = false;
}
async onTokenGrant(event) {
event.preventDefault();
event.stopPropagation();
let grantResult = await this.openDialog(
await this.loadContent(
'projects/bypass/dialogs/BypassTokenGrantDialog',
{ title: 'Grant a token' },
{ models: { 'company': this.company, 'tokens': this.tokens }, profile: this.profile }
)
);
if(grantResult) {
ui.growl.append('Token granted', 'success');
let ConsumeResult = await this.openDialog(
await this.loadContent(
'projects/bypass/dialogs/BypassTokenGrantProposalDialog',
{ title: 'Activate the coaching for...', subtitle: `${this.company.itemData.legalname} (${this.company.itemData.pic})` },
{
models: { users: this.users, token: this.tokens },
profile: this.profile,
tokenId: this.company.itemData.token.id,
pic: this.company.itemData.pic,
legalname: this.company.itemData.legalname,
wizard: true,
}
)
);
}
this.refreshTokensList();
}
async onTokenRevoke(event) {
event.preventDefault();
event.stopPropagation();
let result = await this.openDialog(
await this.loadContent(
'projects/bypass/dialogs/BypassTokenRevokeDialog',
{ title: 'Revoke a token for...', subtitle: `${event.currentTarget.dataset.legalname} (${event.currentTarget.dataset.pic})` },
{
model: this.company,
profile: this.profile,
pic: event.currentTarget.dataset.pic,
track: event.currentTarget.dataset.track,
domain: event.currentTarget.dataset.domain,
}
)
);
if(result) {
ui.growl.append('Token revoked', 'success');
this.refreshTokensList();
}
}
async onTokenView(event) {
event.preventDefault();
event.stopPropagation();
let result = await this.openDialog(
await this.loadContent(
'projects/bypass/dialogs/BypassTokenGrantProposalDialog',
{ title: 'Token granted to ', subtitle: `${event.currentTarget.dataset.legalname} (${event.currentTarget.dataset.pic})` },
{
models: { users: this.users, token: this.tokens },
mode: 'read',
profile: this.profile,
tokenId: event.currentTarget.dataset.id,
pic: event.currentTarget.dataset.pic,
legalname: event.currentTarget.dataset.legalname,
wizard: false,
}
)
);
if(result) {
ui.growl.append('Token consumed', 'success');
this.refreshTokensList();
}
}
async onTokenConsume(event) {
event.preventDefault();
event.stopPropagation();
let result = await this.openDialog(
await this.loadContent(
'projects/bypass/dialogs/BypassTokenGrantProposalDialog',
{ title: 'Activate the coaching for...', subtitle: `${event.currentTarget.dataset.legalname} (${event.currentTarget.dataset.pic})` },
{
models: { users: this.users, token: this.tokens },
profile: this.profile,
tokenId: event.currentTarget.dataset.id,
pic: event.currentTarget.dataset.pic,
legalname: event.currentTarget.dataset.legalname,
wizard: false,
}
)
);
if(result) {
ui.growl.append('Token consumed', 'success');
this.refreshTokensList();
}
}
}
app.registerClass('BypassAdminDashboard', BypassAdminDashboard);
@@ -0,0 +1,42 @@
<div>
<section>
<article eiccard collapsable>
<header>
<h1>Users Access</h1>
<h2></h2>
</header>
<section>
<div eicdatagrid class="users-list"></div>
</section>
<footer>
<div class="cols-1 right"><button eicbutton primary small class="user-add">add user...</button></div>
</footer>
</article>
</section>
<section class="cols-2">
<article eiccard collapsable>
<header>
<h1>EIC Fast Track Settings</h1>
<h2>Yearly token assignment per domain</h2>
</header>
<section>
<div eicdatagrid class="kic-domains-list" footer="hidden"></div>
</section>
<footer>
<div class="cols-1 right"><button class="kic-edit" eicbutton primary small>update...</button></div>
</footer>
</article>
<article eiccard collapsable>
<header>
<h1>EIC Plug In Settings</h1>
<h2>Yearly token assignment per domain</h2>
</header>
<section>
<div eicdatagrid class="plugin-domains-list" footer="hidden"></div>
</section>
<footer>
<div class="cols-1 right"><button class="plugin-edit" eicbutton primary small>update...</button></div>
</footer>
</article>
</section>
</div>
@@ -0,0 +1,104 @@
class BypassAdminManagementContent extends BypassBaseManagementContent {
DOMContentLoaded(options) {
super.DOMContentLoaded(options);
let components = ui.eicfy(this.el);
this.gridKics = new DataGrid(this.find('.kic-domains-list'), {
headers: [ { label: 'Thematic'}, { label: 'Tokens' }, { label: 'Remaining' } ],
height: '320px'
});
this.gridPlugins = new DataGrid(this.find('.plugin-domains-list'), {
headers: [ { label: 'Country'}, { label: 'Tokens'}, { label: 'Remaining' } ],
height: '320px'
});
this.pluginEditButton = components.find(component => component.el.classList.contains('plugin-edit'));
if(this.tokens.hasPrivilege('setSettings')) {
this.pluginEditButton.addEventListener('click', this.onPluginEdit.bind(this));
} else {
ui.hide(this.pluginEditButton);
}
this.kicEditButton = components.find(component => component.el.classList.contains('kic-edit'));
if(this.tokens.hasPrivilege('setSettings')) {
this.kicEditButton.addEventListener('click', this.onKicEdit.bind(this));
} else {
ui.hide(this.kicEditButton);
}
this.gridKics.loading = true;
this.tokens.getSettings('d7xAg5kIhQYeDMB1H6eXnBg')
.then(this.fillKics.bind(this));
this.gridPlugins.loading = true;
this.tokens.getSettings('daTai94ymStyRbQWybH3eDw')
.then(this.fillPlugins.bind(this));
}
fillKics(payload) {
this.gridKics.clear();
this.gridKics.loading = false;
for(let item of payload.domains) {
this.gridKics.addRow(item.domain, [app.meta.toString('accelerator-tracks', item.domain), item.tokens, item.remaining])
}
this.gridKics.sort(1, 'asc');
}
fillPlugins(payload) {
this.gridPlugins.clear();
this.gridPlugins.loading = false;
for(let item of payload.domains) {
this.gridPlugins.addRow(item.domain, [app.meta.toString('accelerator-tracks', item.domain), item.tokens, item.remaining])
}
this.gridPlugins.sort(1, 'asc');
}
async onKicEdit(event) {
event.preventDefault();
event.stopPropagation();
let result = await this.openDialog(
await this.loadContent(
'projects/bypass/dialogs/BypassTrackSetupDialog',
{ title: 'Fast-Track Yearly token attribution', subtitle: 'Set up the yearly token attribution per domain' },
{ model: this.tokens, track: 'd7xAg5kIhQYeDMB1H6eXnBg' }
)
);
if(result) {
ui.growl.append('Settings updated', 'success');
this.gridKics.loading = true;
this.tokens.getSettings('d7xAg5kIhQYeDMB1H6eXnBg')
.then(this.fillKics.bind(this));
}
}
async onPluginEdit(event) {
event.preventDefault();
event.stopPropagation();
let result = await this.openDialog(
await this.loadContent(
'projects/bypass/dialogs/BypassTrackSetupDialog',
{ title: 'Plugins Yearly token attribution', subtitle: 'Set up the yearly token attribution per country' },
{ model: this.tokens, track: 'daTai94ymStyRbQWybH3eDw' }
)
);
if(result) {
ui.growl.append('Settings updated', 'success');
this.gridPlugins.loading = true;
this.tokens.getSettings('daTai94ymStyRbQWybH3eDw')
.then(this.fillPlugins.bind(this));
}
}
}
app.registerClass('BypassAdminManagementContent', BypassAdminManagementContent);
@@ -0,0 +1,117 @@
class BypassBaseManagementContent extends EICDomContent {
constructor() {
super();
Object.assign(this, app.helpers.basicDialogs)
}
DOMContentLoaded(options) {
this.profile = options.profile;
for(let model in options.models) this[model] = options.models[model];
this.users.getRoles(); // We'll need them later, but quite slow
this.gridUsers = new DataGrid(this.find('.users-list'), {
headers: [
{ label: 'Login', filter: 'text', sortable: true},
{ label: 'Email', filter: 'text', sortable: true},
{ label: 'Fullname', filter: 'text', sortable: true},
{ label: 'Role', filter: 'list', sortable: true},
{ label: 'Scheme', filter: 'list', sortable: true},
{ label: 'Domain', filter: 'list', sortable: true},
],
height: 'calc(100vh - 620px)',
actions: []
});
this.btUsersRefresh = new Button(null, {icon: 'icon-refresh', severity: 'primary', rounded: true, size: 'small', hint: 'Refresh the list'})
this.btUsersRefresh.addEventListener('click', this.refreshUsersList.bind(this));
this.gridUsers.el.querySelector('header .cell.actions').append(this.btUsersRefresh.el);
this.userAddButton = this.find('.user-add');
if(this.users.hasPrivilege('create')) {
this.userAddButton.addEventListener('click', this.onUserCreate.bind(this));
} else {
ui.hide(this.userAddButton);
}
this.gridUsers.loading = true;
this.users.list().then(this.fillUsers.bind(this));
}
fillUsers() {
this.gridUsers.clear();
this.gridUsers.loading = false;
this.btUsersRefresh.loading = false;
for (const item of this.users.collection) {
let row = this.gridUsers.addRow(item.uid, [
item.uid,
item.email,
`${item.firstname} ${item.lastname}`,
item.BProle,
item.track.label,
item.domain.label,
], true);
if(this.users.hasPrivilege('revoke') && (item.uid != app.User.identity.uuid )) {
let button = ui.create(`<button eicbutton xsmall danger title="revoke this user" data-uid="${item.uid}">revoke</button>`);
button.addEventListener('click', this.onUserRevoke.bind(this));
row.querySelector('.cell.actions').appendChild(button);
}
}
this.gridUsers.updateFilters();
}
refreshUsersList(event) {
if(event) {
event.preventDefault();
event.stopPropagation();
}
this.btUsersRefresh.loading = true;
this.gridUsers.loading = true;
this.users.list().then(this.fillUsers.bind(this));
}
async onUserCreate(event) {
event.preventDefault();
event.stopPropagation();
let result = await this.openDialog(await this.loadContent('projects/bypass/dialogs/BypassUserProfileDialog',{
title: 'Add a user',
subtitle: 'Please first enter the user UUID then define role and scopes of activity'
}, {
model: this.users,
mode: 'create',
profile: this.profile
}));
if(result) {
ui.growl.append('User added', 'success');
this.refreshUsersList();
}
}
async onUserRevoke(event) {
event.preventDefault();
event.stopPropagation();
let uid = event.currentTarget.dataset.uid;
let user = this.users.getUid(uid);
let result = await this.confirmDialog({
title: 'Revoke a user',
message: `Please be aware that you are about to revoke the user "<strong>${user.firstname} ${user.lastname}</strong>"<br>
This is permanent !<br>
Are you positive ?`,
okLabel: 'Revoke user',
okPromise:() => { return(this.users.revoke(uid)) },
})
if(result) {
ui.growl.append('User revoked', 'success');
this.refreshUsersList();
}
}
}
app.registerClass('BypassBaseManagementContent', BypassBaseManagementContent);
+43
View File
@@ -0,0 +1,43 @@
<article eiccard class="fasttracks dashboard">
<header>
<h1>EIC Fast Track</h1>
<h2>Monitoring for entity/programme <b info>${app.meta.toString('accelerator-tracks', profile.domain)}</b></h2>
</header>
<section>
<div class="tabs-extended">
<section>
<menu eictab>
<li>Tokens</li>
<li>History</li>
<li class="settings">Settings</li>
</menu>
</section>
</div>
<article eiccard class="tokens content">
<section>
<div class="cols-2 right middle">
<div class="metrics">
<div class="available"><span>0</span><label>available</label></div>
<div class="assigned-past"><span>0</span><label>assigned <span class="year"></span></label></div>
<div class="assigned-current"><span>0</span><label>assigned <span class="year"></span></label></div>
<div class="consumed-past"><span success>0</span><label success>consumed <span class="year"></span></label></div>
<div class="consumed-current"><span success>0</span><label success>consumed <span class="year"></span></label></div>
</div>
<button eicbutton primary class="grant-token">Grant a token</button>
</div>
<div eicdatagrid class="granted-tokens" footer="hidden"></div>
</section>
</article>
<article eiccard class="history content">
<section>
<div eicdatagrid class="history-tokens"></div>
</section>
</article>
<article eiccard class="config content">
<section>
<div eicdatagrid class="users" footer="hidden"></div>
</section>
</article>
</section>
</article>
+359
View File
@@ -0,0 +1,359 @@
class BypassKICDashboard extends EICDomContent {
DOMContentLoaded(options) {
ui.eicfy(this.el);
this.profile = options.profile;
for(let model in options.models) this[model] = options.models[model];
this.tabs = new Tab();
this.tabs.addTabs(this.findAll('menu li'), this.findAll('.content'))
this.gridTokens = new DataGrid(this.find('.granted-tokens'), {
headers: [
{ label: 'PIC', type:'markup', filter: 'text', sortable: true},
{ label: 'Company name', filter: 'text', sortable: true},
{ label: 'Entity/programme', filter: 'list', sortable: true},
{ label: 'Token granted', type: 'date', filter: 'text', sortable: true},
{ label: 'Status', filter: 'list', sortable: true},
{ label: 'Project', filter: 'text', sortable: true},
{ label: 'Short proposal submission', type: 'date', filter: 'text', sortable: true}
],
height: 'calc(100vh - 472px)',
actions: []
});
this.btTokenDl = new Button(null, {icon: 'icon-download', severity: 'secondary', rounded: true, size: 'small', hint: 'Download complete list as CSV'})
this.btTokenDl.addEventListener('click', this.downloadTokensList.bind(this));
this.gridTokens.el.querySelector('header .cell.actions').append(this.btTokenDl.el);
this.btTokenRefresh = new Button(null, {icon: 'icon-refresh', severity: 'primary', rounded: true, size: 'small', hint: 'Refresh the list'})
this.btTokenRefresh.addEventListener('click', this.refreshTokensList.bind(this));
this.gridTokens.el.querySelector('header .cell.actions').append(this.btTokenRefresh.el);
this.gridTokens.onRowFiltered = this.refreshMetrics.bind(this);
this.gridHistory = new DataGrid(this.find('.history-tokens'), {
headers: [
{ label: 'Date', type: 'dateTime', filter: 'text', sortable: true},
{ label: 'PIC', filter: 'text', sortable: true},
{ label: 'Company name', filter: 'text', sortable: true},
{ label: 'Action', filter: 'list', sortable: true},
{ label: 'Managed by', filter: 'text', sortable: true},
],
height: 'calc(100vh - 420px)',
actions: []
});
let btGrant = this.find('.grant-token');
if(this.company.hasPrivilege('grant')) {
btGrant.addEventListener('click', this.onTokenGrant.bind(this));
} else {
ui.hide(btGrant);
}
if(this.tokens.hasPrivilege('list')) { this.refreshTokensList(); }
if(this.users) {
this.loadContent(
'projects/bypass/BypassKICManagementContent',
{ title: 'Accelerator configuration', subtitle: 'Users access & token management' },
{ profile: this.profile, models: { "users": this.users } }
).then(view => this.setupUserManagement(view))
} else {
ui.hide(this.find('li.settings'));
}
}
setupUserManagement(view) {
this.userManagement = view;
this.find('.content.config').append(view.el);
}
fill() {
let year = new Date().getFullYear();
let awardedPast = this.tokens.awardedTokens(year - 1);
awardedPast.forEach(item => item.ref = 'assigned-past');
let awardedCurrent = this.tokens.awardedTokens(year);
awardedCurrent.forEach(item => item.ref = 'assigned-current');
let consumedPast = this.tokens.consumedTokens(year - 1);
consumedPast.forEach(item => item.ref = 'consumed-past');
let consumedCurrent = this.tokens.consumedTokens(year);
consumedCurrent.forEach(item => item.ref = 'consumed-current');
let history = this.tokens.history();
this.find('.metrics .available span').innerText = this.tokens.freeTokens - awardedCurrent.length;
this.find('.metrics .assigned-current .year').innerText = year;
this.find('.metrics .consumed-current .year').innerText = year;
this.find('.metrics .assigned-past .year').innerText = year - 1;
this.find('.metrics .consumed-past .year').innerText = year - 1;
this.gridHistory.loading = false;
this.gridTokens.loading = false;
this.btTokenRefresh.loading = false;
this.gridTokens.clear();
let awarded = awardedCurrent.concat(awardedPast);
awarded.sort((a,b) => a.itemData.granted < b.itemData.granted ? 1: -1);
for (const item of awarded) {
let priority = ''
switch(item.itemData.status) {
case 'consumed':
priority = 'success';
break;
}
let row = this.gridTokens.addRow(item.itemData.id, [
`<a href="#" data-id="${item.itemData.id}" data-pic="${item.itemData.enterprise.pic}" data-legalname="${item.itemData.enterprise.legalname}" title="View token details">${item.itemData.enterprise.pic}</a>`,
item.itemData.enterprise.legalname,
app.meta.toString('accelerator-tracks', item.itemData.domain),
item.itemData.granted,
`<span ${priority}>${item.itemData.status}</span>`,
item.itemData.project ? item.itemData.project.acronym: '',
item.itemData.project ? item.itemData.project.submissionDate: ''
], true);
let tokenLink = row.querySelector('.cell:nth-child(2) a')
tokenLink.addEventListener('click', this.onTokenView.bind(this));
row.classList.add(item.ref);
// checking if token can be revoked (meaning status is allocated and user has privilege for that action)
if((item.itemData.status == "allocated") && this.company.hasPrivilege('revoke') && (!item.itemData.isAssignedToShortProposal)) {
let buttonBar = ui.create(`<div class="cols-2"></div>`)
let revokeButton = ui.create(`<button eicbutton xsmall danger title="revoke this token" data-pic="${item.itemData.enterprise.pic}" data-legalname="${item.itemData.enterprise.legalname}" data-track="${item.itemData.track}" data-domain="${item.itemData.domain}" >Revoke</button>`);
revokeButton.addEventListener('click', this.onTokenRevoke.bind(this));
buttonBar.appendChild(revokeButton);
let useButton = ui.create(`<button eicbutton xsmall info title="Activate the coaching" data-id="${item.itemData.id}" data-pic="${item.itemData.enterprise.pic}" data-legalname="${item.itemData.enterprise.legalname}" data-track="${item.itemData.track}" data-domain="${item.itemData.domain}" >Consume</button>`);
useButton.addEventListener('click', this.onTokenConsume.bind(this));
buttonBar.appendChild(useButton);
row.querySelector('.cell.actions').appendChild(buttonBar);
} else if((item.itemData.status == "allocated") && this.company.hasPrivilege('revoke') && (item.itemData.isAssignedToShortProposal)){
row.querySelector('.cell.actions').appendChild(ui.create(`<span>Consuming...</span>`));
}
}
this.gridTokens.updateFilters();
// filling the history list
this.gridHistory.clear();
for (const item of history) {
let priority = ''
switch(item.action) {
case 'revoked': priority = 'danger'; break;
case 'consumed': priority = 'success'; break;
}
let row = this.gridHistory.addRow(item.date, [
item.date,
item.pic,
item.legalname,
`<span ${priority}>${item.action}</span>`,
`${item.actor.firstname ? item.actor.firstname[0] + '. ': ''}${item.actor.lastname}`
], true);
}
this.gridHistory.updateFilters();
this.refreshMetrics();
}
refreshMetrics() {
let rows = Array.from(this.gridTokens.filteredRows);
let assignedPast = rows.filter(item => item.classList.contains('assigned-past'));
let assignedCurrent = rows.filter(item => item.classList.contains('assigned-current'));
let consumedPast = rows.filter(item => item.classList.contains('consumed-past'));
let consumedCurrent = rows.filter(item => item.classList.contains('consumed-current'));
this.find('.metrics .assigned-past span').innerText = assignedPast.length;
this.find('.metrics .assigned-current span').innerText = assignedCurrent.length;
this.find('.metrics .consumed-past span').innerText = consumedPast.length;
this.find('.metrics .consumed-current span').innerText = consumedCurrent.length;
}
refreshTokensList(event) {
if(event) {
event.preventDefault();
event.stopPropagation();
}
this.btTokenRefresh.loading = true;
this.gridTokens.loading = true;
this.gridHistory.loading = true;
this.tokens.list().then(this.fill.bind(this));
}
downloadTokensList(event) {
if(event) {
event.preventDefault();
event.stopPropagation();
}
this.btTokenDl.loading = true;
this.tokens.export({filter: { includePersons: true }}).then(this.buildCSV.bind(this));
}
buildCSV(payload) {
let options = {
headers: [
'PIC',
'Organisation Name',
'Granted on',
'Scheme',
'Entity',
'Status',
'Project',
'Submission date',
'Contact name',
'Contact gender',
'Contact position',
'Contact email',
'Contact phone',
'SPOC name',
'SPOC email',
],
filename: 'EIC-Bypass',
delimiter: ';'
}
let data = [];
for(let token of payload.tokens) {
let row = [
token.enterprise.pic,
token.enterprise.legalname,
token.granted,
app.meta.toString('accelerator-tracks', token.track),
app.meta.toString('accelerator-tracks', token.domain),
token.history[0].status,
token.project ? token.project.acronym: '',
token.project ? token.project.submissiondate: '',
token.shortProposal ? token.shortProposal.contact.lastname + ' ' + token.shortProposal.contact.firstname: '',
token.shortProposal ? token.shortProposal.contact.gender: '',
token.shortProposal ? token.shortProposal.contact.position: '',
token.shortProposal ? token.shortProposal.contact.email: '',
token.shortProposal ? token.shortProposal.contact.phone: '',
token.spoc ? token.spoc.lastname + ' ' + token.spoc.firstname: '',
token.spoc ? token.spoc.email: '',
]
data.push(row);
}
app.helpers.translator.toCSV( data, options );
this.btTokenDl.loading = false;
}
async onTokenGrant(event) {
event.preventDefault();
event.stopPropagation();
let grantResult = await this.openDialog(
await this.loadContent(
'projects/bypass/dialogs/BypassTokenGrantDialog',
{ title: 'Grant a token' },
{ models: { 'company': this.company, 'tokens': this.tokens }, profile: this.profile }
)
);
if(grantResult) {
ui.growl.append('Token granted', 'success');
let ConsumeResult = await this.openDialog(
await this.loadContent(
'projects/bypass/dialogs/BypassTokenGrantPropaDialog',
{ title: 'Activate the coaching for...', subtitle: `${this.company.itemData.legalname} (${this.company.itemData.pic})` },
{
models: { users: this.users, token: this.tokens },
profile: this.profile,
tokenId: this.company.itemData.token.id,
pic: this.company.pic,
legalname: this.company.itemData.legalname,
wizard: true,
}
)
);
}
this.refreshTokensList();
}
async onTokenRevoke(event) {
event.preventDefault();
event.stopPropagation();
let result = await this.openDialog(
await this.loadContent(
'projects/bypass/dialogs/BypassTokenRevokeDialog',
{ title: 'Revoke a token for...', subtitle: `${event.currentTarget.dataset.legalname} (${event.currentTarget.dataset.pic})` },
{
model: this.company,
profile: this.profile,
pic: event.currentTarget.dataset.pic,
track: event.currentTarget.dataset.track,
domain: event.currentTarget.dataset.domain,
}
)
);
if(result) {
ui.growl.append('Token revoked', 'success');
this.refreshTokensList();
}
}
async onTokenView(event) {
event.preventDefault();
event.stopPropagation();
let result = await this.openDialog(
await this.loadContent(
'projects/bypass/dialogs/BypassTokenGrantProposalDialog',
{ title: 'Token granted to ', subtitle: `${event.currentTarget.dataset.legalname} (${event.currentTarget.dataset.pic})` },
{
models: { users: this.users, token: this.tokens },
mode: 'read',
profile: this.profile,
tokenId: event.currentTarget.dataset.id,
pic: event.currentTarget.dataset.pic,
legalname: event.currentTarget.dataset.legalname,
wizard: false,
}
)
);
if(result) {
ui.growl.append('Token consumed', 'success');
this.refreshTokensList();
}
}
async onTokenConsume(event) {
event.preventDefault();
event.stopPropagation();
let result = await this.openDialog(
await this.loadContent(
'projects/bypass/dialogs/BypassTokenGrantProposalDialog',
{ title: 'Activate the coaching for...', subtitle: `${event.currentTarget.dataset.legalname} (${event.currentTarget.dataset.pic})` },
{
models: { users: this.users, token: this.tokens },
profile: this.profile,
tokenId: event.currentTarget.dataset.id,
pic: event.currentTarget.dataset.pic,
legalname: event.currentTarget.dataset.legalname,
wizard: false,
}
)
);
if(result) {
ui.growl.append('Token consumed', 'success');
this.refreshTokensList();
}
}
}
app.registerClass('BypassKICDashboard', BypassKICDashboard);
@@ -0,0 +1,16 @@
<div>
<section>
<article eiccard collapsable>
<header>
<h1>Users Access</h1>
<h2></h2>
</header>
<section>
<div eicdatagrid class="users-list"></div>
</section>
<footer>
<div class="cols-1 right"><button eicbutton primary small class="user-add">add user...</button></div>
</footer>
</article>
</section>
</div>
@@ -0,0 +1,7 @@
class BypassKICManagementContent extends BypassBaseManagementContent {
DOMContentLoaded(options) { super.DOMContentLoaded(options); }
}
app.registerClass('BypassKICManagementContent', BypassKICManagementContent);
+42
View File
@@ -0,0 +1,42 @@
<article eiccard class="fasttracks dashboard">
<header>
<h1>EIC Plug In</h1>
<h2>Monitoring for country <b info>${app.meta.toString('accelerator-tracks', profile.domain)}</b></h2>
</header>
<section>
<div class="tabs-extended">
<section>
<menu eictab>
<li>Tokens</li>
<li>History</li>
<li class="settings">Settings</li>
</menu>
</section>
</div>
<article eiccard class="tokens content">
<section>
<div class="cols-2 right middle">
<div class="metrics">
<div class="available"><span>0</span><label>available</label></div>
<div class="assigned-past"><span>0</span><label>assigned <span class="year"></span></label></div>
<div class="assigned-current"><span>0</span><label>assigned <span class="year"></span></label></div>
<div class="consumed-past"><span success>0</span><label success>consumed <span class="year"></span></label></div>
<div class="consumed-current"><span success>0</span><label success>consumed <span class="year"></span></label></div>
</div>
<button eicbutton primary class="grant-token">Grant a token</button>
</div>
<div eicdatagrid class="granted-tokens" footer="hidden"></div>
</section>
</article>
<article eiccard class="history content">
<section>
<div eicdatagrid class="history-tokens"></div>
</section>
</article>
<article eiccard class="config content">
<section>
<div eicdatagrid class="users" footer="hidden"></div>
</section>
</article>
</section>
</article>
+364
View File
@@ -0,0 +1,364 @@
class BypassPluginDashboard extends EICDomContent {
DOMContentLoaded(options) {
ui.eicfy(this.el);
this.profile = options.profile;
for(let model in options.models) this[model] = options.models[model];
this.tabs = new Tab();
this.tabs.addTabs(this.findAll('menu li'), this.findAll('.content'))
this.gridTokens = new DataGrid(this.find('.granted-tokens'), {
headers: [
{ label: 'PIC', type: 'markup', filter: 'text', sortable: true},
{ label: 'Company name', filter: 'text', sortable: true},
{ label: 'Country', filter: 'list', sortable: true},
{ label: 'Token granted', type: 'date', filter: 'text', sortable: true},
{ label: 'Status', filter: 'list', sortable: true},
{ label: 'Project', filter: 'text', sortable: true},
{ label: 'Short proposal submission', type: 'date', filter: 'text', sortable: true},
],
height: 'calc(100vh - 472px)',
actions: []
});
this.btTokenDl = new Button(null, {icon: 'icon-download', severity: 'secondary', rounded: true, size: 'small', hint: 'Download complete list as CSV'})
this.btTokenDl.addEventListener('click', this.downloadTokensList.bind(this));
this.gridTokens.el.querySelector('header .cell.actions').append(this.btTokenDl.el);
this.btTokenRefresh = new Button(null, {icon: 'icon-refresh', severity: 'primary', rounded: true, size: 'small', hint: 'Refresh the list'})
this.btTokenRefresh.addEventListener('click', this.refreshTokensList.bind(this));
this.gridTokens.el.querySelector('header .cell.actions').append(this.btTokenRefresh.el);
this.gridTokens.onRowFiltered = this.refreshMetrics.bind(this);
this.gridHistory = new DataGrid(this.find('.history-tokens'), {
headers: [
{ label: 'Date', type: 'dateTime', filter: 'text', sortable: true},
{ label: 'PIC', filter: 'text', sortable: true},
{ label: 'Company name', filter: 'text', sortable: true},
{ label: 'Action', filter: 'list', sortable: true},
{ label: 'Managed by', filter: 'text', sortable: true},
],
height: 'calc(100vh - 420px)',
actions: []
});
let btGrant = this.find('.grant-token');
if(this.company.hasPrivilege('grant')) {
btGrant.addEventListener('click', this.onTokenGrant.bind(this));
} else {
ui.hide(btGrant);
}
if(this.tokens.hasPrivilege('list')) { this.refreshTokensList(); }
if(this.users) {
this.loadContent(
'projects/bypass/BypassPluginManagementContent',
{ title: 'Accelerator configuration', subtitle: 'Users access & token management' },
{ profile: this.profile, models: { users: this.users, tokens: this.tokens } }
).then(view => this.setupUserManagement(view))
} else {
ui.hide(this.find('li.settings'));
}
}
setupUserManagement(view) {
this.userManagement = view;
this.find('.content.config').append(view.el);
}
fill() {
let year = new Date().getFullYear();
let awardedPast = this.tokens.awardedTokens(year - 1);
awardedPast.forEach(item => item.ref = 'assigned-past');
let awardedCurrent = this.tokens.awardedTokens(year);
awardedCurrent.forEach(item => item.ref = 'assigned-current');
let consumedPast = this.tokens.consumedTokens(year - 1);
consumedPast.forEach(item => item.ref = 'consumed-past');
let consumedCurrent = this.tokens.consumedTokens(year);
consumedCurrent.forEach(item => item.ref = 'consumed-current');
let history = this.tokens.history();
this.find('.metrics .available span').innerText = this.tokens.freeTokens - awardedCurrent.length;
this.find('.metrics .assigned-current .year').innerText = year;
this.find('.metrics .consumed-current .year').innerText = year;
this.find('.metrics .assigned-past .year').innerText = year - 1;
this.find('.metrics .consumed-past .year').innerText = year - 1;
this.gridHistory.loading = false;
this.gridTokens.loading = false;
this.btTokenRefresh.loading = false;
this.gridTokens.clear();
let awarded = awardedCurrent.concat(awardedPast);
awarded.sort((a,b) => a.itemData.granted < b.itemData.granted ? 1: -1);
for (const item of awarded) {
let priority = ''
switch(item.itemData.status) {
case 'consumed':
priority = 'success';
break;
}
let row = this.gridTokens.addRow(item.itemData.id, [
`<a href="#" data-id="${item.itemData.id}" data-pic="${item.itemData.enterprise.pic}" data-legalname="${item.itemData.enterprise.legalname}" title="View token details">${item.itemData.enterprise.pic}</a>`,
item.itemData.enterprise.legalname,
app.meta.toString('accelerator-tracks', item.itemData.domain),
item.itemData.granted,
`<span ${priority}>${item.itemData.status}</span>`,
item.itemData.project ? item.itemData.project.acronym: '',
item.itemData.project ? item.itemData.project.submissionDate: ''
], true);
let tokenLink = row.querySelector('.cell:nth-child(2) a')
tokenLink.addEventListener('click', this.onTokenView.bind(this));
row.classList.add(item.ref);
// checking if token can be revoked (meaning status is allocated and user has privilege for that action)
if((item.itemData.status == "allocated") && this.company.hasPrivilege('revoke') && (!item.itemData.isAssignedToShortProposal)) {
let buttonBar = ui.create(`<div class="cols-2"></div>`)
let revokeButton = ui.create(`<button eicbutton xsmall danger title="revoke this token" data-pic="${item.itemData.enterprise.pic}" data-legalname="${item.itemData.enterprise.legalname}" data-track="${item.itemData.track}" data-domain="${item.itemData.domain}" >Revoke</button>`);
revokeButton.addEventListener('click', this.onTokenRevoke.bind(this));
buttonBar.appendChild(revokeButton);
let useButton = ui.create(`<button eicbutton xsmall info title="Activate the coaching" data-id="${item.itemData.id}" data-pic="${item.itemData.enterprise.pic}" data-legalname="${item.itemData.enterprise.legalname}" data-track="${item.itemData.track}" data-domain="${item.itemData.domain}" >Consume</button>`);
useButton.addEventListener('click', this.onTokenConsume.bind(this));
buttonBar.appendChild(useButton);
row.querySelector('.cell.actions').appendChild(buttonBar);
} else if((item.itemData.status == "allocated") && this.company.hasPrivilege('revoke') && (item.itemData.isAssignedToShortProposal)){
row.querySelector('.cell.actions').appendChild(ui.create(`<span>Consuming...</span>`));
}
}
this.gridTokens.updateFilters();
// filling the history list
this.gridHistory.clear();
for (const item of history) {
let priority = ''
switch(item.action) {
case 'revoked': priority = 'danger'; break;
case 'consumed': priority = 'success'; break;
}
let row = this.gridHistory.addRow(item.date, [
item.date,
item.pic,
item.legalname,
`<span ${priority}>${item.action}</span>`,
`${item.actor.firstname ? item.actor.firstname[0] + '. ': ''}${item.actor.lastname}`
], true);
}
this.gridHistory.updateFilters();
this.refreshMetrics();
}
refreshMetrics() {
let rows = Array.from(this.gridTokens.filteredRows);
let assignedPast = rows.filter(item => item.classList.contains('assigned-past'));
let assignedCurrent = rows.filter(item => item.classList.contains('assigned-current'));
let consumedPast = rows.filter(item => item.classList.contains('consumed-past'));
let consumedCurrent = rows.filter(item => item.classList.contains('consumed-current'));
this.find('.metrics .assigned-past span').innerText = assignedPast.length;
this.find('.metrics .assigned-current span').innerText = assignedCurrent.length;
this.find('.metrics .consumed-past span').innerText = consumedPast.length;
this.find('.metrics .consumed-current span').innerText = consumedCurrent.length;
}
refreshTokensList(event) {
if(event) {
event.preventDefault();
event.stopPropagation();
}
this.btTokenRefresh.loading = true;
this.gridTokens.loading = true;
this.gridHistory.loading = true;
this.tokens.list().then(this.fill.bind(this));
}
downloadTokensList(event) {
if(event) {
event.preventDefault();
event.stopPropagation();
}
this.btTokenDl.loading = true;
this.tokens.export({filter: { includePersons: true }}).then(this.buildCSV.bind(this));
}
buildCSV(payload) {
let options = {
headers: [
'PIC',
'Organisation Name',
'Granted on',
'Scheme',
'Entity',
'Program',
'Status',
'Project',
'Submission date',
'Contact name',
'Contact gender',
'Contact position',
'Contact email',
'Contact phone',
'SPOC name',
'SPOC email',
],
filename: 'EIC-Bypass',
delimiter: ';'
}
let data = [];
for(let token of payload.tokens) {
let row = [
token.enterprise.pic,
token.enterprise.legalname,
token.granted,
app.meta.toString('accelerator-tracks', token.track),
app.meta.toString('accelerator-tracks', token.domain),
token.program || '',
token.history[0].status,
token.project ? token.project.acronym: '',
token.project ? token.project.submissiondate: '',
token.shortProposal ? token.shortProposal.contact.lastname + ' ' + token.shortProposal.contact.firstname: '',
token.shortProposal ? token.shortProposal.contact.gender: '',
token.shortProposal ? token.shortProposal.contact.position: '',
token.shortProposal ? token.shortProposal.contact.email: '',
token.shortProposal ? token.shortProposal.contact.phone: '',
token.spoc ? token.spoc.lastname + ' ' + token.spoc.firstname: '',
token.spoc ? token.spoc.email: '',
]
data.push(row);
}
app.helpers.translator.toCSV( data, options );
this.btTokenDl.loading = false;
}
async onTokenGrant(event) {
event.preventDefault();
event.stopPropagation();
let grantResult = await this.openDialog(
await this.loadContent(
'projects/bypass/dialogs/BypassTokenGrantDialog',
{ title: 'Grant a token' },
{ models: { 'company': this.company, 'tokens': this.tokens }, profile: this.profile }
)
);
if(grantResult) {
ui.growl.append('Token granted', 'success');
let ConsumeResult = await this.openDialog(
await this.loadContent(
'projects/bypass/dialogs/BypassTokenGrantPropaDialog',
{ title: 'Activate the coaching for...', subtitle: `${this.company.itemData.legalname} (${this.company.itemData.pic})` },
{
models: { users: this.users, token: this.tokens },
mode: 'edit',
profile: this.profile,
tokenId: this.company.itemData.token.id,
pic: this.company.pic,
legalname: this.company.itemData.legalname,
wizard: true,
}
)
);
}
this.refreshTokensList();
}
async onTokenRevoke(event) {
event.preventDefault();
event.stopPropagation();
let result = await this.openDialog(
await this.loadContent(
'projects/bypass/dialogs/BypassTokenRevokeDialog',
{ title: 'Revoke a token for...', subtitle: `${event.currentTarget.dataset.legalname} (${event.currentTarget.dataset.pic})` },
{
model: this.company,
profile: this.profile,
pic: event.currentTarget.dataset.pic,
track: event.currentTarget.dataset.track,
domain: event.currentTarget.dataset.domain,
}
)
);
if(result) {
ui.growl.append('Token revoked', 'success');
this.refreshTokensList();
}
}
async onTokenView(event) {
event.preventDefault();
event.stopPropagation();
let result = await this.openDialog(
await this.loadContent(
'projects/bypass/dialogs/BypassTokenGrantProposalDialog',
{ title: 'Token granted to ', subtitle: `${event.currentTarget.dataset.legalname} (${event.currentTarget.dataset.pic})` },
{
models: { users: this.users, token: this.tokens },
mode: 'read',
profile: this.profile,
tokenId: event.currentTarget.dataset.id,
pic: event.currentTarget.dataset.pic,
legalname: event.currentTarget.dataset.legalname,
wizard: false,
}
)
);
if(result) {
ui.growl.append('Token consumed', 'success');
this.refreshTokensList();
}
}
async onTokenConsume(event) {
event.preventDefault();
event.stopPropagation();
let result = await this.openDialog(
await this.loadContent(
'projects/bypass/dialogs/BypassTokenGrantProposalDialog',
{ title: 'Activate the coaching for...', subtitle: `${event.currentTarget.dataset.legalname} (${event.currentTarget.dataset.pic})` },
{
models: { users: this.users, token: this.tokens },
mode: 'edit',
profile: this.profile,
tokenId: event.currentTarget.dataset.id,
pic: event.currentTarget.dataset.pic,
legalname: event.currentTarget.dataset.legalname,
wizard: false,
}
)
);
if(result) {
ui.growl.append('Token consumed', 'success');
this.refreshTokensList();
}
}
}
app.registerClass('BypassPluginDashboard', BypassPluginDashboard);
@@ -0,0 +1,30 @@
<div>
<section>
<article eiccard collapsable>
<header>
<h1>Users Access</h1>
<h2></h2>
</header>
<section>
<div eicdatagrid class="users-list"></div>
</section>
<footer>
<div class="cols-1 right"><button eicbutton primary small class="user-add">add user...</button></div>
</footer>
</article>
</section>
<section>
<article eiccard collapsable class="programmes-article">
<header>
<h1>Programme Settings</h1>
<h2>Programmes managed by your country</h2>
</header>
<section>
<div eicdatagrid class="programmes-list" footer="hidden"></div>
</section>
<footer>
<div class="cols-1 right"><button class="programme-create" eicbutton primary small>add programme...</button></div>
</footer>
</article>
</section>
</div>
@@ -0,0 +1,98 @@
class BypassPluginManagementContent extends BypassBaseManagementContent {
DOMContentLoaded(options) {
super.DOMContentLoaded(options);
Object.assign(this, app.helpers.basicDialogs)
let components = ui.eicfy(this.el);
this.programsArticle = components.find(component => component.el.classList.contains('programmes-article'));
this.programsGrid = new DataGrid(this.find('.programmes-list'), {
headers: [ { label: 'Program'} ],
height: '320px',
actions: []
});
let programmeBt = this.find('.programme-create');
if(this.tokens.hasPrivilege('addProgram')) {
programmeBt.addEventListener('click', this.onProgramAdd.bind(this));
} else {
ui.hide(programmeBt);
}
this.programsGrid.loading = true;
this.tokens.getPrograms(this.profile.track, this.profile.domain)
.then(this.fillPrograms.bind(this));
}
fillPrograms(payload) {
this.programsGrid.clear();
this.programsGrid.loading = false;
for(let item of payload) {
let row = this.programsGrid.addRow(0, [ item ]);
// checking if programme can be deleted
if(this.tokens.hasPrivilege('removeProgram')) {
let button = ui.create(`<button eicbutton small danger title="delete this programme" data-label="${item}" data-track="${this.profile.track}" data-domain="${this.profile.domain}" >delete</button>`);
button.addEventListener('click', this.onProgramDelete.bind(this));
row.querySelector('.cell.actions').appendChild(button);
}
}
}
async onProgramAdd(event) {
event.preventDefault();
event.stopPropagation();
let result = await this.openDialog(
await this.loadContent(
'projects/bypass/dialogs/BypassProgramAddDialog',
{ title: 'Add a programme' },
{ model: this.tokens, profile: this.profile }
)
);
if(result) {
ui.growl.append('Programme added', 'success');
this.programsGrid.loading = true;
this.tokens.getPrograms(this.profile.track, this.profile.domain)
.then((payload) => {
this.fillPrograms(payload);
this.programsArticle.expand();
})
}
}
async onProgramDelete(event) {
event.preventDefault();
event.stopPropagation();
let result = await this.confirmDialog({
title: 'Delete this programme',
message: `Please be aware that you are about to delete the programme "<strong>${event.target.dataset.label}</strong>"<br>
Are you positive ?`,
okLabel: 'Delete',
okPromise:() => {
let payload = {
track: event.target.dataset.track,
domain: event.target.dataset.domain,
program: event.target.dataset.label
}
// this promise should always return a result (awaited above)
// either built here or from the model action.
return(this.tokens.removeProgram(payload))
}
});
if(result) {
this.programsGrid.loading = true;
this.tokens.getPrograms(this.profile.track, this.profile.domain)
.then(this.fillPrograms.bind(this));
}
}
}
app.registerClass('BypassPluginManagementContent', BypassPluginManagementContent);
@@ -0,0 +1,8 @@
<div class="fasttracks ">
<section class="program">
<input type="hidden" name="track" value="" />
<input type="hidden" name="domain" value="" />
<label>Programme name</label>
<input type="text" class="required" eicinput name="program" value="" />
</section>
</div>
@@ -0,0 +1,54 @@
class BypassProgramAddDialog extends EICDialogContent {
actions = [
{
label: 'Cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel'
},
{
label: 'Create programme',
onclick: this.add.bind(this),
severity: 'primary',
role: 'add',
disabled: false
}
]
constructor(options) { super(options); }
DOMContentLoaded(options) {
this.model = options.model;
this.profile = options.profile;
let components = ui.eicfy(this.el);
this.form = new Form(this.find('.program'));
this.find('input[name="track"]').value = this.profile.track;
this.find('input[name="domain"]').value = this.profile.domain;
}
add() {
if(this.form.validate()) {
this.actions.find(o => o.role == 'add').button.loading = true;
this.actions.find(o => o.role == 'cancel').button.disabled = true;
this.model.addProgram(this.form.value)
.then( this.onSuccess.bind(this), this.onFailed.bind(this) )
}
}
onSuccess() {
this.actions.find(o => o.role == 'add').button.loading = false;
this.actions.find(o => o.role == 'cancel').button.disabled = false;
this.commit(true);
}
onFailed() {
this.actions.find(o => o.role == 'add').button.loading = false;
this.actions.find(o => o.role == 'cancel').button.disabled = false;
this.abort();
}
}
app.registerClass('BypassProgramAddDialog',BypassProgramAddDialog);
@@ -0,0 +1,34 @@
<div class="fasttracks ">
<section class="cols-2 right top search">
<input eicinput type="search" class="required validate-number" data-path="query" hint="The PIC number of a company is at least 8 digit long number" placeholder="Enter a PIC number..." />
<button eicbutton primary icon="icon-search" class="search"></button>
</section>
<section class="result">
</section>
<section class="explanation">
<input type="hidden" data-path="pic" value="" />
<alert eicalert info>In order to complete your request, please fill in the following</alert>
<p><span class="company"></span> should get access to the Accelerator under this specific scheme:</p>
<section class="cols-2">
<div>
<label>Scheme</label>
<select eicselect data-path="track" class="required"></select>
</div>
<div>
<label>Entity</label>
<select eicselect data-path="domain" class="required"></select>
</div>
</section>
<section>
<textarea name="justification" eictextarea class="required"
placeholder="Please explain why you are proposing this company for the EIC Accelerator"></textarea>
<div class="programme">
<label>Related programme</label>
<select data-path="programme" eicselect placeholder="Please select a programme..."></select>
</div>
<input eiccheckbox type="checkbox" data-path="justification.independentExpert" data-type="ignore" class="required" label="You confirm that you have relied on independent external experts to perform the project review" value="yes" />
<input eiccheckbox type="checkbox" data-path="justification.rulesCompliant" data-type="ignore" class="required" label="You confirm that you have complied with all the conditions and requirements set out for the scheme" value="yes" />
</section>
</section>
</div>
+216
View File
@@ -0,0 +1,216 @@
class BypassTokenGrantDialog extends EICDialogContent {
actions = [
{
label: 'Cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel'
},
{
label: 'Grant token',
onclick: this.grant.bind(this),
severity: 'primary',
role: 'grant',
disabled: true
}
]
status = '';
DOMContentLoaded(options) {
for(let model in options.models) this[model] = options.models[model];
this.profile = options.profile;
let components = ui.eicfy(this.el);
this.searchbar = this.find('.search');
this.result = this.find('.result');
this.query = components.find(component => component.el.dataset.path == 'query');
this.query.onQuery = this.onSearch.bind(this);
this.trackSelect = components.find(component => component.el.dataset.path == 'track');
this.domainSelect = components.find(component => component.el.dataset.path == 'domain');
this.programmeSelect = components.find(component => component.el.dataset.path == 'programme');
this.searchButton = components.find(component => component.el.classList.contains('search'));
ui.hide(this.find('.programme'));
this.programmeSelect.el.classList.remove('required');
let tracks = app.meta.toOptions(app.meta.getCollection('accelerator-tracks'), this.profile.track, true);
tracks.forEach(item => this.trackSelect.el.append(item));
if(this.profile.track) {
this.fillDomain(this.profile.track, this.profile.domain);
//NIKE : Quickpatch because <option selected> is broken by the famous "couldn't care less update" in eicui
this.trackSelect.value = this.profile.track
this.trackSelect.disabled = true;
} else {
this.trackSelect.change(this.onTrackChange.bind(this));
}
if(this.profile.domain) {
//NIKE : Quickpatch because <option selected> is broken by the famous "couldn't care less update" in eicui
this.domainSelect.value = this.profile.domain
this.domainSelect.disabled = true;
// MFA: added this as it seems when using SPOCs, programmes were not handled
this.fillProgramme(this.trackSelect.value, this.domainSelect.value);
} else {
this.domainSelect.change(this.onDomainChange.bind(this));
}
this.form = new Form(this.find('.explanation'));
ui.hide(this.result)
ui.hide(this.form.el)
this.searchButton.el.addEventListener('click', this.onSearch.bind(this));
}
onTrackChange(event) {
event.preventDefault();
event.stopPropagation();
if(this.trackSelect.value) {
this.fillDomain(this.trackSelect.value);
} else {
this.domainSelect.empty();
}
this.fillProgramme(this.trackSelect.value, this.domainSelect.value);
}
onDomainChange(event) {
event.preventDefault();
event.stopPropagation();
this.fillProgramme(this.trackSelect.value, this.domainSelect.value);
}
fillDomain(track, domain) {
let domains = track ? app.meta.toOptions(app.meta.getItem('accelerator-tracks', track).children, domain, true): [];
this.domainSelect.empty();
domains.forEach(item => this.domainSelect.el.append(item));
}
fillProgramme(track, domain) {
ui.hide(this.find('.programme'));
this.programmeSelect.empty();
this.programmeSelect.el.classList.remove('required');
this.programmeSelect.el.dataset.type = 'ignore';
if(track == 'daTai94ymStyRbQWybH3eDw') {
if(domain) {
ui.lock();
this.tokens.getPrograms(track, domain)
.then(payload => {
ui.unlock();
if(payload.length > 0) {
ui.show(this.find('.programme'));
this.programmeSelect.el.classList.add('required');
this.programmeSelect.el.dataset.type = 'text';
this.programmeSelect.el.append(ui.create('<option value="">(select a program)</option>'))
for(let program of payload) {
this.programmeSelect.el.append(ui.create(`<option value="${program}">${program}</option>`))
}
}
});
}
}
}
onSearch(event) {
if(event) {
event.stopPropagation();
event.preventDefault();
}
this.query.value = this.query.value.trim();
if(this.query.value != '') {
ui.hide(this.result);
this.searchButton.loading = true;
this.company.search(this.query.value)
.then(this.onResult.bind(this), this.onEmptyResult.bind(this));
}
}
onResult(result) {
ui.show(this.result);
this.searchButton.loading = false;
let hasToken = result.token && Object.keys(result.token).length > 0;
let status = hasToken ? 'danger': 'success';
let message = hasToken ?
'This company is already benefitting of the EIC Accelerator':
'This company is elligible for the EIC Accelerator';
this.result.innerHTML = `<div class="company" ${status} class="icon-success">
<div><span class="legalname" primary>${result.legalname}</span></div>
<div class="cols-3 top">
<div><label>PIC</label><span class="pic">${result.pic}</span></div>
<div><label>Country</label><span class="domain">${result.country || '(unknown)'}</span></div>
<div><label>Location</label><span class="domain">${result.city || '(unknown)'}</span></div>
</div>
</div>
<div eicalert ${status} success>${message}</div>`;
this.actions.find(o => o.role == 'grant').button.disabled = true;
// resolving user interaction
if(!hasToken) {
//this.find('[data-path="pic"]').value
this.legalname = result.legalname;
this.status = 'selecting'
this.find('span.company').innerHTML = `${result.legalname} (${result.pic})`;
this.actions.find(o => o.role == 'grant').button.disabled = false;
}
}
onEmptyResult() {
ui.show(this.result);
this.searchButton.loading = false;
this.result.innerHTML = `<div eicalert danger>Sorry, this PIC is not registered</div>`;
this.status = 'selecting';
this.actions.find(o => o.role == 'grant').button.disabled = true;
}
onSelect() {
this.find('[data-path="pic"]').value = this.query.value;
ui.hide(this.searchbar);
ui.hide(this.result);
ui.show(this.form.el);
this.status = 'completing';
}
grant() {
switch(this.status) {
case 'selecting':
this.onSelect();
break;
case 'completing':
if(this.form.validate()) {
this.actions.find(o => o.role == 'grant').button.loading = true;
this.actions.find(o => o.role == 'cancel').button.disabled = true;
this.company.grant(this.company.itemData.pic, this.form.value)
.then( this.onGrantSuccess.bind(this), this.onGrantFailed.bind(this))
}
break;
}
}
onGrantSuccess() {
this.actions.find(o => o.role == 'grant').button.loading = false;
this.actions.find(o => o.role == 'cancel').button.disabled = false;
this.commit(true);
}
onGrantFailed() {
this.actions.find(o => o.role == 'grant').button.loading = false;
this.actions.find(o => o.role == 'cancel').button.disabled = false;
this.abort();
}
}
app.registerClass('BypassTokenGrantDialog',BypassTokenGrantDialog);
@@ -0,0 +1,155 @@
<style>
.fasttracks-propa {
padding: var(--eicui-base-spacing-xl);
transition: all 0.5s;
}
.fasttracks-propa.loading {
padding: var(--eicui-base-spacing-xl);
transition: all 0.5s;
overflow: hidden;
}
.fasttracks-propa .spinner {
text-align: center;
display: none;
animation: spin 1s infinite linear;
}
.fasttracks-propa .content { display: block; }
.fasttracks-propa.loading .spinner { display: block; }
.fasttracks-propa.loading .content { display: none; }
.fasttracks-propa .fields-group { min-height: 490px; }
.fasttracks-propa .fields-group.read { min-height: 380px; }
</style>
<div class="fasttracks-propa loading">
<div large class="spinner icon-spinner">
</div>
<div class="content">
<input eicinput type="hidden" name="pic" data-path="organisation" value="" />
<div eicalert success class="wizard-message">A token has been successfully granted to <span class="company"></span></div>
<p class="instructions">In order to immediately activate the coaching, please fill-in the following informations :</p>
<div class="cols-2 left">
<div class="tabs-extended vertical">
<menu eictab>
<li data-target="token">Token</li>
<li data-target="enterprise">Company <span xxsmall eicbadge danger data-target="enterprise"></span></li>
<li data-target="proposal">Proposal <span xxsmall eicbadge danger data-target="proposal"></span></li>
<li data-target="contact">Contact person <span xxsmall eicbadge danger data-target="contact"></span></li>
</menu>
</div>
<div class="sections">
<article eiccard class="fields-group" data-target="token">
<section class="cols-2">
<div>
<label>granted</label>
<input eicinput name="granted" data-path="token" data-type="ignore" disabled />
</div>
<div>
<label>status</label>
<input eicinput name="status" data-path="" data-type="ignore" disabled />
</div>
<div>
<label>Scheme</label>
<input eicinput name="track" data-path="token" data-type="ignore" disabled />
</div>
<div>
<label>Domain</label>
<input eicinput name="domain" data-path="token" data-type="ignore" disabled />
</div>
</section>
</article>
<article eiccard class="fields-group" data-target="enterprise">
<section>
<div class="cols-2">
<div>
<label>PIC</label>
<input eicinput name="pic" data-path="token.enterprise" data-type="ignore" disabled />
</div>
<div>
<label>Legal name</label>
<input eicinput name="legalname" data-path="token.enterprise" data-type="ignore" disabled />
</div>
</div>
<div>
<label>Type of entity</label>
<select eicselect name="companyType" data-path="organisation" class="required">
<option value=""></option>
<option value="sme">SME</option>
<option value="midcap">Small mid-cap</option>
<option value="person">Natural person</option>
</select>
</div>
</section>
</article>
<article eiccard class="fields-group" data-target="proposal">
<section>
<div class="cols-2">
<div>
<label>Acronym</label>
<input eicinput name="acronym" data-path="proposal" data-type="text" maxlen="50" class="required" />
</div>
<div>
<label>Submitted</label>
<input eicinput name="submissiondate" data-path="token.project" data-type="ignore" disabled />
</div>
</div>
<div>
<label>Full Title</label>
<input eicinput name="title" data-path="proposal" data-type="text" maxlen="200" class="required" />
</div>
<div>
<label>Abstract</label>
<textarea eictextarea name="abstract" data-path="proposal" data-type="text" maxlen="1000" class="required"></textarea>
</div>
</section>
</article>
<article eiccard class="fields-group" data-target="contact">
<section>
<div class="lookup">
<label>Please identify the contact person by the EU-Login</label>
<div class="cols-2 right center">
<input eicinput type="search" name="euLoginId" data-path="contact" class="required" value="" placeholder="Enter member's EU Login ID or email and press enter"/>
<button eicbutton icon="icon-search" class="user-search" primary ></button>
</div>
</div>
<div class="cols-3">
<div>
<label>First name</label>
<input eicinput disabled name="firstname" data-path="contact" data-type="text" class="required" />
</div>
<div>
<label>Last name</label>
<input eicinput disabled name="lastname" data-path="contact" data-type="text" class="required" />
</div>
<div>
<label>Gender</label>
<select eicselect name="gender" data-path="contact" data-type="text" class="required">
<option></option>
<option value="F">Female</option>
<option value="M">Male</option>
<option value="I">I prefer not to declare</option>
</select>
</div>
</div>
<div class="cols-2">
<div>
<label>email</label>
<input eicinput disabled type="email" name="email" data-path="contact" data-type="text" />
</div>
<div>
<label>Phone</label>
<input eicinput type="tel" name="phone" data-path="contact" data-type="text" maxlen="100" />
</div>
</div>
<div>
<label>Position</label>
<select eicselect lookup name="position" data-path="contact" data-type="text" class="required"></select>
</div>
</section>
</article>
</div>
</div>
</div>
</div>
@@ -0,0 +1,220 @@
class BypassTokenGrantProposalDialog extends EICDialogContent {
actions = [
{
label: 'Cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
disabled: false,
role: 'cancel'
},
{
label: 'Assign token to this proposal',
onclick: this.consume.bind(this),
severity: 'primary',
disabled: false,
role: 'consume'
}
]
status = '';
mode = 'edit';
validationInterval = 1000;
DOMContentLoaded(options) {
console.log('==BypassTokenGrantProposalDialog==>', options)
this.models = options.models;
this.mode = options.mode || this.mode;
this.enableWizard = options.wizard;
this.tokenId = options.tokenId;
this.components = ui.eicfy(this.el);
this.find('.fasttracks-propa').classList.add(this.mode)
this.tabs = new Tab();
this.tabs.addTabs(this.findAll('.tabs-extended menu li'), this.findAll('.fields-group'))
let position = this.find('select[name="position"]');
app.meta.toOptions('organisation-functions', null, true).forEach(item => position.append(item));
this.find('[name="pic"]').value = options.pic;
this.euid = this.components.find(component => component.el.name == 'euLoginId');
this.firstname = this.components.find(component => component.el.name == 'firstname');
this.lastname = this.components.find(component => component.el.name == 'lastname');
this.email = this.components.find(component => component.el.name == 'email');
this.userSearchButton = this.components.find(component => component.el.classList.contains('user-search'));
this.cards = this.components.filter(component => component.el.classList.contains('fields-group'))
this.globalForm = new Form(this.el);
this.miniForms = Array.from(this.cards.map(card=>card.el).map(el => new Form(el)))
this.euid.onQuery = this.onUserSearch.bind(this)
this.userSearchButton.el.addEventListener('click', this.onUserSearch.bind(this))
}
DOMContentFocused() {
this.actions.forEach(o => o.button.disabled = true);
this.models.token.viewToken(this.tokenId)
.then(payload => { this.setMode(this.mode, payload) });
}
setMode(mode, payload) {
this.find('.fasttracks-propa').classList.remove('loading');
// cleanup
payload.token.granted = payload.token.granted ? ui.format.dateTime(payload.token.granted): ''
payload.token.track = app.meta.toString('accelerator-tracks', payload.token.track)
payload.token.domain = app.meta.toString('accelerator-tracks', payload.token.domain)
payload.status = payload.token.history[0].status;
if(!payload.token.project) payload.token.project = {}
payload.token.project.submissiondate = payload.token.project.submissiondate ? ui.format.dateTime(payload.token.project.submissiondate): ''
let fields = this.globalForm.scan();
for(let field of fields) {
let component = this.el2component(field.el.dataset.path, field.el.name);
if(component) component.disabled = this.mode == 'read' || field.el.hasAttribute('disabled');
let item = this.findField(payload, field.el.dataset.path, field.el.name)
if(item) {
if(component.el.type == 'checkbox'){
component.el.checked = item == true;
}
else
component.value = item;
}
}
switch(mode) {
case 'read':
ui.hide(this.find('.wizard-message'));
ui.hide(this.find('.lookup'));
ui.hide(this.find('.instructions'));
ui.hide(this.actions.find(o => o.role == 'consume').button.el);
this.actions.find(o => o.role == 'cancel').label = 'Close';
break;
case 'edit':
if(!this.enableWizard)
ui.hide(this.find('.wizard-message'))
else
this.actions.find(o => o.role == 'cancel').label = 'Skip';
this.tabs.selectByIndex(1);
this.validationCheck = setInterval(this.validateTabs.bind(this), this.validationInterval);
break;
}
this.actions.forEach(o => o.button.disabled = false);
}
el2component(path, name) {
let item = this.components
.find(o =>
o.el.name == name &&
( !path || (path && o.el.dataset.path == path))
)
return item;
}
/**
* Retrieves a data field value based on path an name
* @param {*} path
* @param {*} name
* @returns
*/
findField(payload, path, name) {
let levels = path && path != '' && path !== undefined ? path.split('.'): [];
let tree = payload;
for(let level of levels)
if(tree.hasOwnProperty(level)) tree = tree[level];
if(tree.hasOwnProperty(name)) return tree[name];
}
onUserSearch(event) {
this.userSearchButton.loading = true;
this.models.users.search(this.euid.value).then(
(userList => {
this.userSearchButton.loading = false;
if(userList.length==0) return
// TODO better manage if several (take most complete?)
let user = userList[0];
this.euid.value = user.uid;
this.firstname.value = user.given_name;
this.lastname.value = user.family_name;
this.email.value = user.email;
}),
(err) => {
this.userSearchButton.loading = false;
}
);
}
validateTabs() {
let valid = true;
for(let form of this.miniForms) {
let report = form.validate(true)
let badge = this.components.find(item => item.el.hasAttribute('eicbadge') && item.el.dataset.target == form.el.dataset.target)
if(badge) {
badge.value = report.issues;
}
valid = valid && report.valid;
}
this.actions.find(o => o.role == 'consume').button.disabled = !valid;
}
consume() {
if(this.globalForm.validate()) {
this.actions.find(o => o.role == 'consume').button.loading = true;
this.actions.find(o => o.role == 'cancel').button.disabled = true;
this.models.token.consumeToken(this.globalForm.value)
.then( this.onConsumeSuccess.bind(this), this.onConsumeFailed.bind(this))
}
}
onConsumeSuccess() {
this.actions.find(o => o.role == 'consume').button.loading = false;
this.actions.find(o => o.role == 'cancel').button.disabled = false;
this.commit(true);
}
onConsumeFailed() {
this.actions.find(o => o.role == 'consume').button.loading = false;
this.actions.find(o => o.role == 'cancel').button.disabled = false;
this.abort();
}
DOMContentResized() {
super.DOMContentResized();
let device = this.parentContainer.getAttribute('device');
//let tabs = this.find('.tabs-extended');
switch(device) {
case 'desktop':
this.tabs.triggers.forEach(item => ui.show(item));
let current = this.tabs.getSelected();
let target = current.dataset.target;
this.findAll('article.fields-group').forEach(content => { if(content.dataset.target != target) ui.hide(content) });
break;
default:
this.tabs.triggers.forEach(item => ui.hide(item));
this.findAll('article.fields-group').forEach(content => { ui.show(content) })
break;
}
}
}
app.registerClass('BypassTokenGrantProposalDialog',BypassTokenGrantProposalDialog);
@@ -0,0 +1,14 @@
<div class="fasttracks ">
<section class="explanation">
<alert eicalert danger>Please be aware that you are about to revoke an awarded token.</alert>
</section>
<section class="agreement">
<input type="hidden" name="track" value="" />
<input type="hidden" name="domain" value="" />
<textarea data-path="justification" eictextarea maxlen="200" class="required"
hint="No more than 200 characters please."
placeholder="Please explain why you are removing this company for the EIC Accelerator"></textarea>
<input eiccheckbox type="checkbox" name="agreed" data-type="ignore" value="yes" label="I understand" />
</section>
</div>
@@ -0,0 +1,58 @@
class BypassTokenRevokeDialog extends EICDialogContent {
actions = [
{
label: 'Cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel'
},
{
label: 'Revoke token',
onclick: this.revoke.bind(this),
severity: 'danger',
role: 'revoke',
disabled: true
}
]
constructor(options) { super(options); }
DOMContentLoaded(options) {
this.model = options.model;
let components = ui.eicfy(this.el);
this.form = new Form(this.find('.agreement'));
this.pic = options.pic;
this.find('input[name="track"]').value = options.track;
this.find('input[name="domain"]').value = options.domain;
this.optin = this.find('input[name="agreed"]');
this.optin.addEventListener('change',this.onAgreement.bind(this));
}
onAgreement() { this.actions.find(o => o.role == 'revoke').button.disabled = !this.optin.checked; }
revoke() {
if(this.form.validate()) {
this.actions.find(o => o.role == 'revoke').button.loading = true;
this.actions.find(o => o.role == 'cancel').button.disabled = true;
this.model.revoke(this.pic, this.form.value)
.then( this.commit.bind(this), this.abort.bind(this) )
}
}
onRevokeSuccess() {
this.actions.find(o => o.role == 'revoke').button.loading = false;
this.actions.find(o => o.role == 'cancel').button.disabled = false;
this.commit(true);
}
onRevokeFailed() {
this.actions.find(o => o.role == 'revoke').button.loading = false;
this.actions.find(o => o.role == 'cancel').button.disabled = false;
this.abort();
}
}
app.registerClass('BypassTokenRevokeDialog',BypassTokenRevokeDialog);
@@ -0,0 +1,9 @@
<div class="fasttracks settings">
<section class="explanation">
<alert eicalert danger>Please be aware that your changes will take place immediately.</alert>
</section>
<section class="domains">
<input type="hidden" data-path="track" value="${track}" />
<div eicdatagrid footer="hidden"></div>
</section>
</div>
@@ -0,0 +1,80 @@
class BypassTrackSetupDialog extends EICDialogContent {
actions = [
{
label: 'Cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel'
},
{
label: 'Save',
onclick: this.save.bind(this),
severity: 'primary',
role: 'save'
}
]
constructor(options) { super(options); }
DOMContentLoaded(options) {
this.model = options.model;
this.track = options.track;
this.domains = options.domains;
this.grid = new DataGrid(this.find('.domains [eicdatagrid]'), {
headers: [ { label: 'Domain'}, { label: 'Tokens', type: 'markup' }, { label: 'Min.' } ],
height: '320px'
});
this.grid.clear();
this.grid.loading = true;
this.model.getSettings(this.track)
.then(this.fill.bind(this));
}
fill(payload) {
this.grid.clear();
this.grid.loading = false;
let index = 0;
for(let item of payload.domains) {
this.grid.addRow(item.domain, [
app.meta.toString('accelerator-tracks', item.domain),
`<input type="hidden" data-path="domains[${index}].domain" value="${item.domain}" />
<input eicinput type="number" min="${item.tokens - item.remaining}" data-path="domains[${index}].tokens" value="${item.tokens}" />`,
item.tokens - item.remaining
]);
index++;
}
this.grid.sort(1, 'asc');
this.form = new Form(this.find('.domains'));
let components = ui.eicfy(this.el);
}
save() {
if(this.form.validate()) {
this.actions.find(o => o.role == 'save').button.loading = true;
this.actions.find(o => o.role == 'cancel').button.disabled = true;
this.model.setSettings(this.form.value)
.then(this.onSuccess.bind(this))
.catch(this.onFailed.bind(this));
}
}
onSuccess() {
this.actions.find(o => o.role == 'save').button.loading = false;
this.actions.find(o => o.role == 'cancel').button.disabled = false;
this.commit(true);
}
onFailed() {
this.actions.find(o => o.role == 'save').button.loading = false;
this.actions.find(o => o.role == 'cancel').button.disabled = false;
//this.abort();
}
}
app.registerClass('BypassTrackSetupDialog',BypassTrackSetupDialog);
@@ -0,0 +1,51 @@
<div class="user-dialog">
<div>
<label>EU login or email</label>
<div class="cols-2 right top">
<input eicinput type="search" data-path="euid" class="required" value="" />
<button eicbutton icon="icon-search" class="user-search" primary ></button>
</div>
</div>
<div>
<label>Email</label>
<input eicinput disabled value="" class="required email" data-path="email"/>
</div>
<div class="cols-2">
<div>
<label>Firstname</label>
<input eicinput disabled value="" class="required" data-path="firstname"/>
</div>
<div>
<label>Lastname</label>
<input eicinput disabled value="" class="required" data-path="lastname"/>
</div>
</div>
<div>
<label>Gender</label>
<select eicselect class="required" data-path="gender">
<option></option>
<option value="M">Male</option>
<option value="F">Female</option>
<option value="I">Rather not say</option>
</select>
</div>
<div hidden class="roleSelector">
<label>Role</label>
<select eicselect class="required" class="required" data-path="role">
<option></option>
</select>
</div>
<div class="cols-3">
<div hidden class="trackSelector">
<label>Track</label>
<select eicselect data-path="track"></select>
</div>
<div hidden class="domainSelector">
<label>Domain</label>
<select eicselect data-path="domain"></select>
</div>
</div>
</div>
@@ -0,0 +1,252 @@
class BypassUserProfileDialog extends EICDialogContent {
actions = [
{
label: 'Cancel',
onclick: this.cancel.bind(this),
severity: 'secondary',
role: 'cancel'
},
{
label: 'Add user',
onclick: this.create.bind(this),
severity: 'primary',
role: 'add',
disabled: true
},
]
async DOMContentLoaded(options) {
this.mode = options.mode ? options.mode: 'create';
this.model = options.model;
this.profile = options.profile;
let components = ui.eicfy(this.el);
this.euid = components.find(component => component.el.dataset.path == 'euid');
this.firstname = components.find(component => component.el.dataset.path == 'firstname');
this.lastname = components.find(component => component.el.dataset.path == 'lastname');
this.email = components.find(component => component.el.dataset.path == 'email');
this.genderSelect = components.find(component => component.el.dataset.path == 'gender');
this.roleSelect = components.find(component => component.el.dataset.path == 'role');
this.roleSelect.block = this.find('.roleSelector')
this.trackSelect = components.find(component => component.el.dataset.path == 'track');
this.trackSelect.block = this.find('.trackSelector')
this.domainSelect = components.find(component => component.el.dataset.path == 'domain');
this.domainSelect.block = this.find('.domainSelector');
this.userSearcButton = components.find(component => component.el.classList.contains('user-search'));
this.genderSelect.change(this.changeRoleAvailable.bind(this));
this.roleSelect.change(this.onRoleChange.bind(this));
this.roleSelect.disabled = true;
this.trackSelect.change(this.onTrackChange.bind(this));
this.trackSelect.disabled = true;
this.domainSelect.change(this.onDomainChange.bind(this));
this.domainSelect.disabled = true;
this.form = new Form(this.find('.user-dialog'));
let tracks = app.meta.toOptions(app.meta.getCollection('accelerator-tracks'), null, true);
tracks.forEach(item => this.trackSelect.el.append(item));
this.roleDelegations = {}
this.roleDelegations = await app.Assets.loadJson({name:'global/bypass/roleDelegations.json'})
this.userSearcButton.el.addEventListener('click', this.onUserSearch.bind(this));
}
changeRoleAvailable() {
if( this.form.validateField(this.euid).valid &&
this.form.validateField(this.firstname).valid &&
this.form.validateField(this.lastname).valid &&
this.form.validateField(this.email).valid &&
this.form.validateField(this.genderSelect).valid
) {
this.roleSelect.disabled = false;
this.changeAddAvailable();
let roles4select = this.getDelegationRules().map(x=>x.role);
let options = app.meta.toOptions(roles4select.map(x=>({id:x, label:x})), '', true)
this.roleSelect.empty();
options.forEach(item => this.roleSelect.el.append(item));
ui.show(this.roleSelect.block);
} else {
this.hideSelect(this.roleSelect);
}
}
changeAddAvailable() {
if(this.form.validate() && this.validateDelegation()) {
this.actions.find(o => o.role == 'add').button.disabled = false;
} else {
this.actions.find(o => o.role == 'add').button.disabled = true;
}
}
getDelegationRules() {
/* In delegations, which of my roles shall I use as master... (no role hierarchy)
For now priority is top-down in the roleDelegation masters order.
Bad, not 100% reliable, but in our case, there should be no conflicts (mutually exclusive in delegations)
*/
let delegationsRules = [];
for(let roleDeleg of this.roleDelegations){
if(app.User.hasRole(roleDeleg.master)) {
delegationsRules = roleDeleg.surrogates;
break;
}
}
return(delegationsRules)
}
hideSelect(sel) {
sel.disabled = true;
sel.value = '';
ui.hide(sel.block);
}
ruleSelect(rule, sel, inheritValue) {
if(rule=='inherit') { // Forced to my own value
sel.disabled = true;
sel.value = inheritValue;
ui.show(sel.block);
} else if(rule=='set') { // Can choose
sel.disabled = false;
ui.show(sel.block);
} else if(rule=='ignore'){ // Not applicable
this.hideSelect(sel);
} else { // Forced to fixed value
sel.disabled = true;
sel.value = rule;
ui.show(sel.block);
}
}
validateDelegation(){
let rules = this.getDelegationRules().find(x => x.role==this.roleSelect.value);
if(!rules) return(false)
const validateSel = (rule, sel, inheritValue) => {
let ok = false;
if(rule=='inherit') ok = (sel.value == inheritValue)
else if(rule=='set') ok = Boolean(sel.value)
else if(rule=='ignore') ok = (true)
else ok = (sel.value == rule)
if(ok) sel.el.nextSibling.classList.remove('validation-failed');
else sel.el.nextSibling.classList.add('validation-failed');
return(ok)
}
return( validateSel(rules.track, this.trackSelect, this.profile.track) &&
validateSel(rules.domain, this.domainSelect, this.profile.domain)
)
}
onRoleChange(event) {
if(event) {
event.preventDefault();
event.stopPropagation();
}
// pas de role: pas de track, pas de track : pas de domaine, pas de domaine: pas de palais, etc
if(!this.roleSelect.value){
this.hideSelect(this.trackSelect);
this.hideSelect(this.domainSelect);
return
}
let rules = this.getDelegationRules().find(x => x.role==this.roleSelect.value);
if(!rules) {
console.warn('Delegation not found for role '+this.roleSelect.value);
this.hideSelect(this.trackSelect);
this.hideSelect(this.domainSelect);
return
}
this.ruleSelect(rules.track, this.trackSelect, this.profile.track);
this.onTrackChange();
this.ruleSelect(rules.domain, this.domainSelect, this.profile.domain);
this.changeAddAvailable();
}
onUserSearch(event) {
this.userSearcButton.loading = true;
this.model.search(this.euid.value).then(
(userList => {
this.userSearcButton.loading = false;
if(userList.length==0) return
// TODO better manage if several (take most complete?)
let user = userList[0];
this.euid.value = user.uid;
this.firstname.value = user.given_name;
this.lastname.value = user.family_name;
this.email.value = user.email;
this.changeRoleAvailable();
}),
(err) => {
this.userSearcButton.loading = false;
}
);
}
onTrackChange(event) {
if(event){
event.preventDefault();
event.stopPropagation();
}
if(this.trackSelect.value) {
this.fillDomain(this.trackSelect.value);
} else {
this.domainSelect.empty();
this.hideSelect(this.domainSelect);
}
this.changeAddAvailable();
}
onDomainChange(event) {
event.preventDefault();
event.stopPropagation();
this.changeAddAvailable();
}
fillDomain(track, domain) {
let domains = track ? app.meta.toOptions(app.meta.getItem('accelerator-tracks', track).children, domain, true): [];
this.domainSelect.empty();
domains.forEach(item => this.domainSelect.el.append(item));
}
DOMContentFocused() {
}
create(event) {
if(this.form.validate() && this.validateDelegation()) {
this.actions.find(o => o.role == 'add').button.loading = true;
this.actions.find(o => o.role == 'cancel').button.disabled = true;
this.model.create(this.form.value)
.then( this.onSuccess.bind(this), this.onFailed.bind(this))
}
}
onSuccess() {
this.actions.find(o => o.role == 'add').button.loading = false;
this.actions.find(o => o.role == 'cancel').button.disabled = false;
this.commit(true);
}
onFailed() {
this.actions.find(o => o.role == 'add').button.loading = false;
this.actions.find(o => o.role == 'cancel').button.disabled = false;
this.abort();
}
}
app.registerClass('BypassUserProfileDialog', BypassUserProfileDialog);

Some files were not shown because too many files have changed in this diff Show More