unclean SPARC
This commit is contained in:
Executable
+264
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* @category MyEic
|
||||
* @subcategory Libraries
|
||||
*/
|
||||
class ChatModule{
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} appContent
|
||||
*/
|
||||
constructor(appContent) {
|
||||
this.appContent = appContent;
|
||||
this.unreadChats = {};
|
||||
this.chatTargets = {};
|
||||
this.onlineUsers = [];
|
||||
this.recipientId = null;
|
||||
this.currentHistory = [];
|
||||
|
||||
this.initView();
|
||||
|
||||
//TODO: inherit API entry_points
|
||||
// this.APmeta.xxxx
|
||||
this.APIEntries = {
|
||||
'chatTargets' :'/api/chatTargets',
|
||||
'':'',
|
||||
|
||||
};
|
||||
|
||||
//TODO: Do this on opening the chat-panel ? => when is it updated ?
|
||||
this.getConversations()
|
||||
.then( this.updateChanList.bind(this) )
|
||||
.then( this.RequestOnlineUsers.bind(this));
|
||||
this.addMsgBusEvents();
|
||||
this.RequestUnreadMsgs();
|
||||
}
|
||||
|
||||
addMsgBusEvents(){
|
||||
app.events.addEvent('MessageBus.Connected', this.RequestUnreadMsgs.bind(this));
|
||||
app.events.addEvent('MessageBus.Closed', this.updateUnreadBadge.bind(this));
|
||||
app.events.addEvent('MessageBus.NOTIFS', this.updateUnreadBadge.bind(this));
|
||||
app.events.addEvent('MessageBus.ISONLINE', this.updateOnlineBadges.bind(this));
|
||||
app.events.addEvent('MessageBus.CHANHIST', this.replaceHisto.bind(this));
|
||||
app.events.addEvent('MessageBus.CHATMSG', this.recvBus.bind(this));
|
||||
}
|
||||
|
||||
RequestUnreadMsgs(){
|
||||
return(
|
||||
app.MessageBus.requestWssGwAction('NOTIFS', null).then(
|
||||
(payload) => {
|
||||
this.unreadChats = payload.unreadChats;
|
||||
//TODO: share the other stuff with friends... (app.something ??)
|
||||
this.updateUnreadBadge();
|
||||
},
|
||||
(err)=>{ console.warn('MSGBUS error:',err) })
|
||||
);
|
||||
}
|
||||
|
||||
RequestOnlineUsers(){
|
||||
let usrlist = Object.keys(this.chatTargets.users);
|
||||
// Request them now
|
||||
app.MessageBus.requestWssGwAction('ISONLINE', usrlist);
|
||||
// Watch them
|
||||
app.MessageBus.requestWssGwAction('WATCHUSERS', usrlist);
|
||||
}
|
||||
|
||||
getConversations(){
|
||||
//TODO use app central fetcher ??? => use fake model like in myUser
|
||||
return(fetch(this.APIEntries.chatTargets)
|
||||
.then((response) => response.json()));
|
||||
}
|
||||
|
||||
recvBus(e){
|
||||
let histoDetail = {};
|
||||
histoDetail[e.detail.msg.histId] = {
|
||||
'msg' : e.detail.msg.msg,
|
||||
'sender' : e.detail.msg.sender
|
||||
};
|
||||
this.replaceHisto({ 'detail': histoDetail });
|
||||
}
|
||||
|
||||
replaceHisto(e){
|
||||
let hids = Object.keys(e.detail);
|
||||
for(let hid of hids){ // Insert without creating dups
|
||||
if(!(hid in this.currentHistory)) this.currentHistory[hid] = e.detail[hid];
|
||||
}
|
||||
let html = ''; let day = '';
|
||||
let user, acro, dat, timestp;
|
||||
for(let hid of Object.keys(this.currentHistory).sort()){
|
||||
user = this.chatTargets.users[this.currentHistory[hid].sender];
|
||||
acro = user.given_name[0].toUpperCase()+'. '+user.family_name;
|
||||
dat = new Date(1*hid.substring(0,hid.indexOf('-')));
|
||||
timestp = dat.toLocaleString('fr').replace('/'+new Date().getFullYear(),''); // hide current year
|
||||
if((day != '') && (day != dat.getDate())) {
|
||||
html += `<div class="day-separator">${(dat.getDate()==(new Date()).getDate())?'(today)':''}</div>`;
|
||||
}
|
||||
html += `<div class="histo-entry ${(this.currentHistory[hid].sender==app.currentUser.userInfo.sub)?'me':''}">
|
||||
<span class="sender">${acro} (${timestp})</span><br>
|
||||
${this.currentHistory[hid].msg.replace(/\n/g, '<br>')}
|
||||
</div><br>`;
|
||||
day = dat.getDate();
|
||||
}
|
||||
this.chatView.el.querySelector('.history').innerHTML = html;
|
||||
this.chatView.el.querySelector('.history').scrollTop = this.chatView.el.querySelector('.history').scrollHeight;
|
||||
}
|
||||
|
||||
updateChanList(chatTargets){
|
||||
this.chatTargets = chatTargets;
|
||||
for(var uid in chatTargets.users){
|
||||
if(uid != app.currentUser.userInfo.sub){
|
||||
this.addUserChannel(uid, chatTargets.users[uid]);
|
||||
}
|
||||
}
|
||||
for(var chid in chatTargets.lobbies){
|
||||
this.addLobbyChannel(chid, chatTargets.lobbies[chid]);
|
||||
}
|
||||
}
|
||||
|
||||
addUserChannel(uid, user) {
|
||||
let acro = user.given_name[0].toUpperCase()+'. '+user.family_name;
|
||||
this.chanlist.addRow('P:'+uid, [ '<span eicbadge danger class="userled"></span>', acro ]);
|
||||
}
|
||||
|
||||
addLobbyChannel(chid, lobby) {
|
||||
this.chanlist.addRow('C:'+chid, [ '<span eicbadge info class="lobbyled"></span>', lobby.name]);
|
||||
}
|
||||
|
||||
initView() {
|
||||
this.chatView = {
|
||||
el: null,
|
||||
channels: [],
|
||||
active: false,
|
||||
currentChannel: null
|
||||
};
|
||||
this.chatView.el = new DropDown(ui.create(`
|
||||
<div eicdropdown>
|
||||
<button eicbutton rounded basic small primary class="icon-comment chat-menu"></button>
|
||||
<menu eicmenu>
|
||||
<li menuitem>
|
||||
<div class="eic-chat">
|
||||
<div class="chanlist"></div>
|
||||
<div class="lobbyname">Lobby name...</div>
|
||||
<div class="history"></div>
|
||||
<textarea eictextarea class="message" placeholder="Type message here..."></textarea>
|
||||
<button eicbutton small primary class="send" eicicon="icon-send"></button>
|
||||
</div>
|
||||
</li>
|
||||
</menu>
|
||||
</div>
|
||||
`)).el;
|
||||
ui.eicfy(this.chatView.el);
|
||||
this.appContent.find('header .eic-session').prepend(this.chatView.el);
|
||||
this.chanlist = new DataGrid(this.appContent.find('.chanlist'), {
|
||||
headers: [
|
||||
{label: '', sortable:true },
|
||||
{label: 'name', filter: 'text', sortable:true},
|
||||
],
|
||||
height: '350px',
|
||||
});
|
||||
this.chanlist.onRowClick= this.selectChan.bind(this);
|
||||
this.chatView.el.querySelector('.send').addEventListener('click', this.onSendMessage.bind(this));
|
||||
this.chatView.el.querySelector('.message').addEventListener("keyup", this.onKeyUp.bind(this));
|
||||
this.updateUnreadBadge();
|
||||
this.chatView.tab = new Tab();
|
||||
}
|
||||
|
||||
onKeyUp(event) {
|
||||
if(this.aftertypeTo) clearTimeout(this.aftertypeTo);
|
||||
if(event.which == 13 && !event.shiftKey) {
|
||||
this.onSendMessage(event);
|
||||
return
|
||||
}
|
||||
this.aftertypeTo = setTimeout(() => {
|
||||
this.chatView.el.querySelector('.message').value = this.emotiAscii2Utf(this.chatView.el.querySelector('.message').value);
|
||||
}, 900);
|
||||
}
|
||||
|
||||
emotiAscii2Utf(txt) {
|
||||
let conv = {
|
||||
':-)':'🙂',':)':'🙂',';-)':'😉',';)':'😉',':-D':'😄','x-D':'😂',':-P':'😛',
|
||||
':P':'😛',':-|':'😑',':|':'😑',':-(':'🙁',':(':'🙁',":'-)":'😂',":')":'😂',
|
||||
":'-(":'😢',":'(":'😢','>:(':'😠','>:[':'😡',':-*':'😘',':*':'😘','O:-)':'😇',
|
||||
'O:)':'😇',':-J':'😏'
|
||||
}
|
||||
for(let emoasci of Object.keys(conv)) {
|
||||
if(emoasci == txt.substring(txt.length-emoasci.length)){
|
||||
return(txt.substring(0,txt.length-emoasci.length)+conv[emoasci]);
|
||||
}
|
||||
}
|
||||
return(txt);
|
||||
}
|
||||
|
||||
selectChan(e){
|
||||
let rawid = e.currentTarget.dataset.id;
|
||||
if((rawid[0]!='P') && (rawid[0]!='C')) return;
|
||||
this.recipientId = rawid;
|
||||
for(let el of this.chatView.el.querySelectorAll(`.chanlist li.row`)){
|
||||
el.style.backgroundColor = '';
|
||||
el.style.color = '';
|
||||
}
|
||||
e.currentTarget.style.backgroundColor = 'var(--eicui-app-toolbar-bg-color)';
|
||||
e.currentTarget.style.color = 'var(--eicui-base-color-white)';
|
||||
this.currentHistory = {};
|
||||
let realRecipientId = rawid.substring(2);
|
||||
let lobbyname;
|
||||
if(this.recipientId[0]=='P') {
|
||||
lobbyname = this.chatTargets.users[realRecipientId].given_name+' '+this.chatTargets.users[realRecipientId].family_name
|
||||
} else if(this.recipientId[0]=='C') {
|
||||
lobbyname = this.chatTargets.lobbies[realRecipientId].name;
|
||||
}
|
||||
this.chatView.el.querySelector('.lobbyname').innerHTML = lobbyname;
|
||||
app.MessageBus.requestWssGwAction('STARTCHAT', this.recipientId);
|
||||
app.MessageBus.requestWssGwAction('CHANHIST', this.recipientId);
|
||||
}
|
||||
|
||||
onSendMessage(e){
|
||||
let txt = this.chatView.el.querySelector('.message').value;
|
||||
app.MessageBus.requestWssGwAction('SENDCHAT', {
|
||||
'recipient' : this.recipientId,
|
||||
'msg': txt
|
||||
})
|
||||
this.chatView.el.querySelector('.message').value = '';
|
||||
}
|
||||
|
||||
updateUnreadBadge(){
|
||||
//updateUnreadBadge is decoupled from the msgbus via nbUnreadMsgs so you can redraw the UI anytime, not depending on a msgbus request
|
||||
let totUnRead = 0;
|
||||
for(let chan in this.unreadChats) totUnRead += this.unreadChats[chan];
|
||||
|
||||
if(!app.MessageBus.connected) {
|
||||
this.chatView.el.querySelector('button.chat-menu').innerHTML = `<span class="icon-warning" warning xsmall></span>`;
|
||||
} else if(totUnRead>0) {
|
||||
this.chatView.el.querySelector('button.chat-menu').innerHTML = `<span eicbadge success xxsmall>${totUnRead}</span>`;
|
||||
} else {
|
||||
this.chatView.el.querySelector('button.chat-menu').innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
updateOnlineBadges(event){
|
||||
this.onlineUsers = event.detail;
|
||||
let el;
|
||||
for(el of this.chatView.el.querySelectorAll(`.eic-chat .userled`)){
|
||||
el.removeAttribute('success');
|
||||
el.setAttribute('danger','');
|
||||
}
|
||||
for(let uid of this.onlineUsers){
|
||||
el = this.chatView.el.querySelector(`[data-id="P:${uid}"] .userled`);
|
||||
if(el) {
|
||||
el.removeAttribute('danger');
|
||||
el.setAttribute('success','');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
app.registerClass('ChatModule', ChatModule);
|
||||
|
||||
|
||||
//TODOs
|
||||
// Lobbies
|
||||
// Return to send, shift-return to CRLF
|
||||
// Limit displayed / requested history
|
||||
// Store my read / unread msg => real unread notifs
|
||||
// Smileys
|
||||
Executable
BIN
Binary file not shown.
Executable
+168
@@ -0,0 +1,168 @@
|
||||
@font-face {
|
||||
font-family: 'NotoColorEmoji';
|
||||
src: url('NotoColorEmoji.ttf') format('truetype');
|
||||
}
|
||||
|
||||
.eic-chat {
|
||||
min-width: 50vw;
|
||||
padding: var(--eicui-base-spacing-m) !important;
|
||||
}
|
||||
.eic-chat section .conversations > div > div {
|
||||
display: grid;
|
||||
grid-gap: 10px;
|
||||
grid-template-rows: auto min-content min-content;
|
||||
}
|
||||
.eic-chat .status {
|
||||
float: right;
|
||||
color: var(--eicui-base-color-white);
|
||||
display: grid;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--eicui-base-color-danger-100);
|
||||
border-radius: 24px;
|
||||
font-size: 1rem;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.eic-chat .status.active {
|
||||
background: var(--eicui-base-color-success-100);
|
||||
}
|
||||
|
||||
|
||||
.eic-chat .chanlist {
|
||||
width: 250px;
|
||||
font-size: .8em;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
min-height:350px;
|
||||
padding: 5px;
|
||||
grid-area: chanlist;
|
||||
}
|
||||
.eic-chat .history{
|
||||
grid-area: history;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
min-height: 300px;
|
||||
width:100%;
|
||||
text-transform: none;
|
||||
max-height: 500px;
|
||||
overflow: scroll;
|
||||
}
|
||||
.eic-chat .lobbyname {
|
||||
grid-area: lobbyname;
|
||||
background-color: var(--eicui-app-toolbar-bg-color);
|
||||
color: var(--eicui-base-color-white);
|
||||
padding: 5px;
|
||||
text-transform: none;
|
||||
}
|
||||
.eic-chat .history .histo-entry{
|
||||
font-family: sans-serif, 'NotoColorEmoji' !important;
|
||||
background-color: #E8E8FF;
|
||||
border-radius: 5px;
|
||||
margin: 2px 5px;
|
||||
padding: 2px 5px;
|
||||
display: inline-block;
|
||||
max-width: 500px;
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.eic-chat .history .day-separator{
|
||||
clear: both;
|
||||
height: 0;
|
||||
border: 0px none transparent;
|
||||
border-top: 2px dotted #777;
|
||||
text-align: center;
|
||||
line-height: 17px;
|
||||
font-size: smaller;
|
||||
}
|
||||
.eic-chat .history .histo-entry.me{
|
||||
float: right;
|
||||
background-color: #D9F2D7;
|
||||
}
|
||||
.eic-chat .history .histo-entry .sender{
|
||||
display: inline-block;
|
||||
font-size: .85em;
|
||||
font-weight: bold;
|
||||
background-color: var(--eicui-app-toolbar-bg-color);
|
||||
color: #FFF;
|
||||
padding: 0 2px 2px 2px;
|
||||
border-radius: 5px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.eic-chat .message{
|
||||
grid-area: message;
|
||||
width:550px;
|
||||
font-family: sans-serif, 'NotoColorEmoji' !important;
|
||||
}
|
||||
.eic-chat .send{
|
||||
grid-area: send;
|
||||
}
|
||||
|
||||
|
||||
.eic-chat {
|
||||
display:grid;
|
||||
grid-template-areas:
|
||||
'chanlist lobbyname lobbyname'
|
||||
'chanlist history history'
|
||||
'chanlist message send';
|
||||
}
|
||||
.eic-chat .chanlist li.row{
|
||||
grid-template-columns: 15px 2fr;
|
||||
}
|
||||
|
||||
.eic-chat .chanlist span.userled{
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
height: calc(var(--eicui-base-spacing-s)*.8);
|
||||
min-width: calc(var(--eicui-base-spacing-s)*.8);
|
||||
border: 1px outset #aaa;
|
||||
}
|
||||
.eic-chat .chanlist span.lobbyled{
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
height: calc(var(--eicui-base-spacing-s)*.8);
|
||||
min-width: calc(var(--eicui-base-spacing-s)*.8);
|
||||
border: 2px outset #00A;
|
||||
}
|
||||
|
||||
.eic-chat .output {
|
||||
overflow-y: auto;
|
||||
height: 350px;
|
||||
background-color: var(--eicui-base-color-white);
|
||||
width: 100%;
|
||||
border: 1px solid var(--eicui-base-color-grey);
|
||||
padding: 10px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.eic-chat .output .emitter {
|
||||
color: var(--eicui-base-color-primary);
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.eic-chat .cols-2.form {
|
||||
height: 60px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.eic-chat .cols-2.form textarea {
|
||||
resize: none;
|
||||
}
|
||||
.eic-chat .lobby {
|
||||
display: grid;
|
||||
grid-gap: var(--eicui-base-spacing-m);
|
||||
}
|
||||
#subchalst{
|
||||
margin: 0 0 0 0;
|
||||
padding: 0 0 0 5px;
|
||||
}
|
||||
#subchalst li{
|
||||
font-size:12px;
|
||||
}
|
||||
.eic-chat .subscriptions,
|
||||
.eic-chat .onlineusers{
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.eic-chat .subscriptions label,
|
||||
.eic-chat .onlineusers label{
|
||||
font-weight:bold;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use strict'
|
||||
/**
|
||||
* @category MyEic
|
||||
* @subcategory Libraries
|
||||
*/
|
||||
class MBRendezVous {
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} appctrl
|
||||
*/
|
||||
constructor(appctrl){
|
||||
if(!app.MessageBus) return
|
||||
this.activeMeetChans = {}
|
||||
this.myUserNotifChan = app.config.messageBus.userNotifChan.replace(/\{uid\}/g, app.User.identity.uuid)
|
||||
app.MessageBus.addBusListener(
|
||||
'rendezVous',
|
||||
[ this.myUserNotifChan ],
|
||||
this.onRendezVousRequest.bind(this)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} chan
|
||||
* @param {*} payload
|
||||
* @param {*} sender
|
||||
*/
|
||||
async onRendezVousRequest(chan, payload, sender){
|
||||
if(! await this.userConfirmation(sender, payload.userMessage)) return
|
||||
let rdvzchan = payload.chanPrefix + crypto.randomUUID()
|
||||
//this.activeMeetChans[] = chan
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} sender
|
||||
* @param {*} userMessage
|
||||
*/
|
||||
async userConfirmation(sender, userMessage){
|
||||
let options = {
|
||||
title: 'Connexion request',
|
||||
message: `<p>The user <b>${sender}</b> invites you to ${userMessage}.</p>
|
||||
`,
|
||||
cancelLabel: 'Refuse',
|
||||
okLabel: 'Accept',
|
||||
severity: 'danger',
|
||||
okPromise: () => new Promise((ok,ko)=>ok())
|
||||
}
|
||||
await this.mainCtrl.loadContent('templates/dialogs/ConfirmDialog', options, options)
|
||||
/*
|
||||
let result = await this.mainView.confirmDialog({
|
||||
title: 'Connexion request',
|
||||
message: `<p>The user <b>${sender}</b> invites you to ${userMessage}.</p>
|
||||
`,
|
||||
cancelLabel: 'Refuse',
|
||||
okLabel: 'Accept',
|
||||
severity: 'danger',
|
||||
okPromise: () => new Promise((ok,ko)=>ok())
|
||||
})
|
||||
|
||||
if(result) {
|
||||
console.log('you said YES')
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} hisUid
|
||||
* @param {*} userMessage
|
||||
* @param {*} timeout
|
||||
* @returns {Promise}
|
||||
*/
|
||||
getRendezVousChan(hisUid, userMessage, timeout){
|
||||
if(!app.MessageBus) return(
|
||||
|
||||
new Promise((resolve, reject) => reject('Cannot rendez-vous without messageBus !'))
|
||||
)
|
||||
|
||||
return(
|
||||
new Promise((resolve, reject) => {
|
||||
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
app.registerClass('MBRendezVous', MBRendezVous);
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* @category MyEic
|
||||
* @subcategory Libraries
|
||||
* @extends EICModel
|
||||
*/
|
||||
class EICBusModel extends EICModel{
|
||||
|
||||
|
||||
/**
|
||||
* Makes a request to WSSGateway
|
||||
*/
|
||||
busWssGwRequest(action, payload = {}){
|
||||
if(!app.MessageBus) return(new Promise((ok, ko) => ko))
|
||||
if(!app.MessageBus.connected){ // bus not connected => Queue until connected, with 20 sec timeout
|
||||
return(
|
||||
app.MessageBus.whenConnectedP(20000).then(()=>{
|
||||
return(app.MessageBus.requestWssGwAction(action, payload))
|
||||
},
|
||||
()=>{
|
||||
console.log('Timeout waiting for connection !')
|
||||
})
|
||||
)
|
||||
}
|
||||
return(app.MessageBus.requestWssGwAction(action, payload))
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes an action request on the bus
|
||||
*/
|
||||
busActionRequest(chan, action, payload = {}){
|
||||
if(!app.MessageBus) return(new Promise((ok, ko) => ko))
|
||||
if(!app.MessageBus.connected){ // bus not connected => Queue until connected, with 20 sec timeout
|
||||
return(
|
||||
app.MessageBus.whenConnectedP(20000).then(()=>{
|
||||
return(app.MessageBus.requestBusAction(chan, action, payload))
|
||||
},
|
||||
()=>{
|
||||
console.log('Timeout waiting for connection !')
|
||||
})
|
||||
)
|
||||
}
|
||||
return(app.MessageBus.requestBusAction(chan, action, payload))
|
||||
}
|
||||
|
||||
busEvent(chan, event, payload) {
|
||||
if( (!app.MessageBus) || (!app.MessageBus.connected) ) return(new Promise((ok, ko) => ko))
|
||||
app.MessageBus.sendEvent(chan, event, payload)
|
||||
}
|
||||
}
|
||||
|
||||
app.registerClass('EICBusModel', EICBusModel)
|
||||
Executable
+347
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* @category MyEic
|
||||
* @subcategory Libraries
|
||||
* @extends Controller
|
||||
*/
|
||||
class EICController extends Controller {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.view2url = {};
|
||||
}
|
||||
|
||||
getViewByClass(className) {
|
||||
let content = Controller._contents.find(o => o.view._className == className)
|
||||
return content ? content.view: null;
|
||||
}
|
||||
|
||||
getViewByURL(url) {
|
||||
let content = Controller._contents.find(o => o.view._url == url)
|
||||
return content ? content.view: null;
|
||||
}
|
||||
|
||||
loadContent(name, options, data) {
|
||||
options = options || {};
|
||||
options.onContentLoaded = this.createContent;
|
||||
return super.loadView(name, options, data);
|
||||
}
|
||||
|
||||
createContent(options, data, html) {
|
||||
|
||||
let container = ui.create('<div></div>');
|
||||
container.innerHTML = Controller.processTemplate(options.name, html, data);
|
||||
|
||||
let view = new app.LoadedClasses[options.className](options);
|
||||
view._className = options.name.replace('.html', '');
|
||||
view._controller = this;
|
||||
view.el = container;
|
||||
view.DOMContentLoaded(data);
|
||||
|
||||
container.setAttribute('sparc-id', view._sparcId);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
loadWindow(name, options, data) {
|
||||
let url = app.Router.currentRoute.realUrl;
|
||||
options = options || {};
|
||||
// if static, redirect to existing instance
|
||||
if(options.static) {
|
||||
/*
|
||||
MFA:
|
||||
Using stored real url instead of classname allows different instance of window based on parameter:
|
||||
eg:
|
||||
having route "/window/{id}", calling "/window/345" won't replace "/window/123"
|
||||
|
||||
Makes me wonder: shouldn't all windows be static anyway then ?
|
||||
*/
|
||||
//let existing = this.getViewByClass(name);
|
||||
let existing = this.getViewByURL(url);
|
||||
|
||||
if(existing) {
|
||||
this.focus(existing._sparcId, data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
options.onContentLoaded = this.createWindow;
|
||||
super.loadView(name, options, data).then(
|
||||
view => {
|
||||
this.view2url[view._sparcId] = url;
|
||||
view._url = url
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
createWindow(options, data, html) {
|
||||
|
||||
if(!(options.className in app.LoadedClasses)){
|
||||
console.error(`Missing view ${options.className} !\nLoad explicitely it from your controller, or as set it as a dependency...`);
|
||||
return null;
|
||||
}
|
||||
|
||||
let view = new app.LoadedClasses[options.className]();
|
||||
view._className = options.name.replace('.html', '');
|
||||
view._controller = this;
|
||||
|
||||
Controller._contents.push({
|
||||
view: view,
|
||||
type: 'window',
|
||||
expanded: options.expanded || false,
|
||||
visible: true,
|
||||
active: false
|
||||
});
|
||||
|
||||
let content = ui.create(`<div class="window">
|
||||
<header class="cols-2 right">
|
||||
<h1>${options.title || ''}</h1>
|
||||
<div class="controls">
|
||||
<button eicbutton data-id="${view._sparcId}" basic primary rounded xsmall class="icon-copy shrink" title="shrink"></button>
|
||||
<button eicbutton data-id="${view._sparcId}" basic primary rounded xsmall class="icon-square-o expand" title="expand"></button>
|
||||
<button eicbutton data-id="${view._sparcId}" basic primary rounded xsmall class="icon-cancel close" title="close"></button>
|
||||
</div>
|
||||
</header>
|
||||
<section></section>
|
||||
</div>`);
|
||||
if(options.expanded) content.setAttribute('expanded','');
|
||||
|
||||
let container = content.querySelector('section');
|
||||
container.innerHTML = Controller.processTemplate(options.name, html, data);
|
||||
|
||||
view.el = content;
|
||||
|
||||
// setting up window controls
|
||||
let expand = content.querySelector('header button.expand');
|
||||
expand.addEventListener('click', view.expand.bind(view));
|
||||
|
||||
let shrink = content.querySelector('header button.shrink');
|
||||
shrink.addEventListener('click', view.shrink.bind(view));
|
||||
|
||||
let close = content.querySelector('header button.close');
|
||||
close.addEventListener('click', this.onclose.bind(this));
|
||||
|
||||
content.addEventListener('click', this.onFocusRequest.bind(this));
|
||||
content.addEventListener('expanded', this.onFocusRequest.bind(this));
|
||||
content.addEventListener('shrinked', this.onFocusRequest.bind(this));
|
||||
|
||||
content.setAttribute('sparc-id', view._sparcId);
|
||||
|
||||
let parent = Controller._template.view.find('.app-workspace');
|
||||
parent.appendChild(content);
|
||||
|
||||
this.addThesaurus(view._sparcId, options.title || 'a window')
|
||||
|
||||
view.DOMContentLoaded(data);
|
||||
view.initWindowEvents();
|
||||
this.focus(view._sparcId);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
openDialog(view) {
|
||||
|
||||
let promise = new Promise(function(resolve) {
|
||||
if(typeof view === 'string') {
|
||||
let message = view;
|
||||
view = new EICDialogContent();
|
||||
view.el = message;
|
||||
}
|
||||
|
||||
let dialog = EICController.createDialog(view);
|
||||
|
||||
function commit(result) {
|
||||
EICController.closeDialog(view._sparcId);
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
function abort(result) {
|
||||
EICController.closeDialog(view._sparcId);
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
view.commit = commit;
|
||||
view.abort = abort;
|
||||
view.buttons.forEach(function(button) {
|
||||
dialog.querySelector('[eicdialog] > [eiccard] > footer').append(button.el);
|
||||
});
|
||||
|
||||
view.DOMContentFocused();
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used by a view that needs to change the current URL
|
||||
* Typically happens when the view allows you to switch to another instance of the business-object
|
||||
* handled by this controiller. Doinbg a history.replaceState in the view is not enough, because
|
||||
* the controller needs to keep track of his current URL (in view2url) for changing
|
||||
* the Browser URL bar when -later- changing window focus.
|
||||
* @param {string} newUrl
|
||||
*/
|
||||
changeUrl(view, newUrl) {
|
||||
history.replaceState(null, null, newUrl)
|
||||
this.view2url[view._sparcId] = newUrl
|
||||
view._url = newUrl
|
||||
}
|
||||
|
||||
static createDialog(dialog, options) {
|
||||
|
||||
options = options || {};
|
||||
|
||||
let container = ui.create(`<div eicdialog sparc-id="${dialog._sparcId}">
|
||||
<article eiccard ${ options.closable || ''}>
|
||||
${ dialog.options.title ? `<header><h1>${dialog.options.title}</h1>${dialog.options.subtitle ? `<h2>${dialog.options.subtitle}</h2>`: '' }</header>`: ''}
|
||||
<section></section>
|
||||
<footer></footer>
|
||||
</article>
|
||||
</div>`);
|
||||
|
||||
ui.eicfy(container);
|
||||
|
||||
container.querySelector('[eiccard] section').append(dialog.el);
|
||||
dialog.parentContainer = container;
|
||||
|
||||
document.body.append(container)
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
static closeDialog(id) { document.body.querySelector(`[sparc-id="${id}"]`).remove(); }
|
||||
|
||||
addThesaurus(id, title) {
|
||||
let container = Controller._template.view.find('.app-content-thesaurus');
|
||||
container.querySelectorAll('[eicchip]').forEach(el => el.setAttribute('secondary', ''))
|
||||
|
||||
let chip = new Chip(null, {label: title, destroyable: true});
|
||||
chip.el.setAttribute('data-id', id);
|
||||
chip.addEventListener('click', this.onSelectThesaurus.bind(this));
|
||||
chip.addEventListener('destroy', this.onRemoveThesaurus.bind(this));
|
||||
|
||||
container.append(chip.el);
|
||||
}
|
||||
|
||||
removeThesaurus(id) {
|
||||
let container = Controller._template.view.find('.app-content-thesaurus');
|
||||
|
||||
let chip = container.querySelector(`[data-id="${id}"]`)
|
||||
if(chip) chip.remove();
|
||||
|
||||
let remaining = container.querySelectorAll('[eicchip]');
|
||||
|
||||
if(remaining.length > 0) {
|
||||
remaining.item(remaining.length - 1).dispatchEvent(new MouseEvent('click'));
|
||||
}
|
||||
}
|
||||
|
||||
close(id) {
|
||||
this.removeThesaurus(id);
|
||||
this.unloadView(Controller.getContentById(id));
|
||||
if(this.view2url.hasOwnProperty(id)) delete(this.view2url[id]);
|
||||
}
|
||||
|
||||
focus(id, data) {
|
||||
this.selectThesaurus(id);
|
||||
let curUrl = new URL(document.location.href).pathname
|
||||
if(this.view2url.hasOwnProperty(id)) {
|
||||
history.replaceState(null, null, this.view2url[id]);
|
||||
curUrl = this.view2url[id]
|
||||
}
|
||||
|
||||
if(app.matomo) app.matomo.logAction('Focus window / '+curUrl)
|
||||
|
||||
let next = Controller.getContentById(id);
|
||||
|
||||
if(Controller._currentContent) {
|
||||
if(next.expanded) {
|
||||
this.blur(Controller._currentContent);
|
||||
Controller._contents.filter(item => item.type == 'window').forEach(item => this.desactivate(item));
|
||||
Controller._contents.filter(item => !item.expanded).forEach(item => this.blur(item));
|
||||
} else {
|
||||
Controller._contents.filter(item => !item.expanded && item.type == 'window').forEach(item => this.attach(item));
|
||||
this.attach(Controller._currentContent);
|
||||
|
||||
let altexpand = Controller._contents.filter(item => item.expanded && item.visible && item.type == 'window');
|
||||
|
||||
if(altexpand.length == 0) {
|
||||
let latest = Controller._contents.reverse().find(item => item.expanded);
|
||||
if(latest) { this.attach(latest); }
|
||||
}
|
||||
}
|
||||
}
|
||||
Controller._currentContent = next;
|
||||
this.activate(Controller._currentContent, data);
|
||||
Controller._currentContent.view.DOMContentResized();
|
||||
}
|
||||
|
||||
desactivate(content) {
|
||||
content.view.el.classList.remove('active');
|
||||
content.active = false;
|
||||
if(content.expanded) this.blur(content);
|
||||
}
|
||||
|
||||
activate(content, data) {
|
||||
Controller._contents.forEach(o => o.active = false);
|
||||
Controller._contents.forEach(o => o.view.el.classList.remove('active'));
|
||||
content.view.el.classList.add('active');
|
||||
content.active = true;
|
||||
|
||||
if(!content.visible) this.attach(content);
|
||||
|
||||
content.view.DOMContentFocused(data);
|
||||
}
|
||||
|
||||
attach(content) {
|
||||
ui.show(content.view.el,!content.expanded ? 'grid': 'block');
|
||||
content.visible = true;
|
||||
}
|
||||
|
||||
blur(content) {
|
||||
ui.hide(content.view.el);
|
||||
content.visible = false;
|
||||
content.active = false;
|
||||
content.view.DOMContentBlured();
|
||||
}
|
||||
|
||||
onclose(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.close(event.currentTarget.dataset.id)
|
||||
}
|
||||
|
||||
onSelectThesaurus(event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
this.focus(event.currentTarget.dataset.id);
|
||||
}
|
||||
|
||||
selectThesaurus(id) {
|
||||
let container = Controller._template.view.find('.app-content-thesaurus');
|
||||
container.querySelectorAll('[eicchip]').forEach(el => el.setAttribute('secondary', ''))
|
||||
container.querySelector(`[eicchip][data-id="${id}"]`).removeAttribute('secondary');
|
||||
}
|
||||
onRemoveThesaurus(event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
this.close(event.currentTarget.dataset.id);
|
||||
}
|
||||
|
||||
onFocusRequest(event) {
|
||||
|
||||
let view = event.currentTarget;
|
||||
while(!view.hasAttribute('sparc-id') && !view.classList.contains('window')) view = view.parentElement;
|
||||
let content = Controller.getContentById(view.getAttribute('sparc-id'));
|
||||
if(!content.active || content.expanded != content.view.expanded || (Controller._currentContent.view._sparcId != content.view._sparcId)) {
|
||||
content.expanded = content.view.expanded;
|
||||
this.focus(view.getAttribute('sparc-id'));
|
||||
}
|
||||
}
|
||||
|
||||
static resize() {
|
||||
Controller._contents.forEach(item => item.view.DOMContentResized())
|
||||
}
|
||||
}
|
||||
|
||||
app.registerClass('EICController', EICController);
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* @category MyEic
|
||||
* @subcategory Libraries
|
||||
* @extends View
|
||||
*/
|
||||
class EICDialogContent extends View {
|
||||
|
||||
icon = null;
|
||||
|
||||
actions = [
|
||||
{
|
||||
label: 'Cancel',
|
||||
onclick: this.cancel.bind(this),
|
||||
severity: 'secondary'
|
||||
},
|
||||
{
|
||||
label: 'OK',
|
||||
onclick: this.accept.bind(this),
|
||||
severity: 'primary'
|
||||
}
|
||||
]
|
||||
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this.options = options || {};
|
||||
window.addEventListener('resize', this.DOMContentResized.bind(this));
|
||||
}
|
||||
|
||||
DOMContentLoaded(options) {
|
||||
|
||||
}
|
||||
|
||||
get buttons() {
|
||||
let list = [];
|
||||
for(let descriptor of this.actions) {
|
||||
if(!descriptor.button) {
|
||||
let button = new Button(ui.create(`<button eicbutton>${descriptor.label}</>`), {
|
||||
icon: descriptor.icon,
|
||||
severity: descriptor.severity || '',
|
||||
disabled: descriptor.disabled || false,
|
||||
onclick: descriptor.onclick
|
||||
});
|
||||
descriptor.button = button;
|
||||
}
|
||||
list.push(descriptor.button) ;
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
cancel(event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
this.abort();
|
||||
}
|
||||
|
||||
accept(event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
this.commit(true);
|
||||
}
|
||||
|
||||
DOMContentResized() {
|
||||
let limits = [
|
||||
{ name: 'desktop', min: 768 },
|
||||
{ name: 'tablet', min: 600 },
|
||||
{ name: 'mobile', min: 0 }
|
||||
];
|
||||
let device = limits.find(item => item.min < window.innerWidth);
|
||||
this.el.closest('[eicdialog], [eicdialog="eicdialog"]').setAttribute('device', device ? device.name: limits[0].name);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
app.registerClass('EICDialogContent', EICDialogContent);
|
||||
Executable
+239
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* @category MyEic
|
||||
* @subcategory Libraries
|
||||
* @extends View
|
||||
*/
|
||||
class EICDomContent extends View {
|
||||
|
||||
icon = null;
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
loadContent(name, options, data) { return this._controller.loadContent(name, options, data); }
|
||||
|
||||
get expanded() { return this.el.hasAttribute('expanded'); }
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
expand() {
|
||||
this.el.setAttribute('expanded','');
|
||||
this.el.dispatchEvent(new CustomEvent('expanded'));
|
||||
this.el.style.left = 'auto';
|
||||
this.el.style.top = 'auto';
|
||||
this.el.style.height = 'auto';
|
||||
this.el.style.width = 'auto';
|
||||
|
||||
this.DOMContentResized()
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
shrink() {
|
||||
this.el.removeAttribute('expanded');
|
||||
this.el.dispatchEvent(new CustomEvent('shrinked'));
|
||||
this.el.style.left = this.el.getBoundingClientRect().x;
|
||||
this.el.style.top = this.el.getBoundingClientRect().y;
|
||||
|
||||
this.DOMContentResized();
|
||||
}
|
||||
|
||||
openDialog(view) {
|
||||
let promise = new Promise(function(resolve) {
|
||||
if(typeof view === 'string') {
|
||||
let content = view;
|
||||
view = new EICDialogContent();
|
||||
view.el = content;
|
||||
}
|
||||
|
||||
let dialog = EICController.createDialog(view);
|
||||
|
||||
function commit(result) {
|
||||
EICController.closeDialog(view._sparcId);
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
function abort(result) {
|
||||
EICController.closeDialog(view._sparcId);
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
view.commit = commit;
|
||||
view.abort = abort;
|
||||
view.buttons.forEach(function(button) {
|
||||
dialog.querySelector('[eicdialog] > [eiccard] > footer').append(button.el);
|
||||
});
|
||||
|
||||
view.DOMContentFocused();
|
||||
view.DOMContentResized();
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
unload() {
|
||||
let button = this.el.closest('.window').querySelector('.controls .close')
|
||||
if(button) button.click();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
initWindowEvents() {
|
||||
this.draggable(this.el);
|
||||
this.resizable(this.el, this.DOMContentResized.bind(this));
|
||||
}
|
||||
|
||||
draggable(element) {
|
||||
let mouseX, mouseY, offsetX = 0, offsetY = 0;
|
||||
|
||||
// mouse button down over the element
|
||||
element.querySelector('header h1').addEventListener('mousedown', onMouseDown);
|
||||
|
||||
/**
|
||||
* Listens to `mousedown` event.
|
||||
*
|
||||
* @param {Object} event - The event.
|
||||
*/
|
||||
function onMouseDown(event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
mouseX = event.clientX;
|
||||
mouseY = event.clientY;
|
||||
offsetX = element.getBoundingClientRect().x;
|
||||
offsetY = element.getBoundingClientRect().y;
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
}
|
||||
|
||||
element.addEventListener('mouseup', onMouseUp);
|
||||
|
||||
/**
|
||||
* Listens to `mouseup` event.
|
||||
*
|
||||
* @param {Object} event - The event.
|
||||
*/
|
||||
function onMouseUp(event) {
|
||||
offsetX = parseInt(element.style.left) || 0;
|
||||
offsetY = parseInt(element.style.top) || 0;
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
}
|
||||
|
||||
/**
|
||||
* Listens to `mousemove` event.
|
||||
*
|
||||
* @param {Object} event - The event.
|
||||
*/
|
||||
function onMouseMove(event) {
|
||||
if(event.buttons != 1) {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
return;
|
||||
}
|
||||
|
||||
let dX = event.clientX - mouseX;
|
||||
let dY = event.clientY - mouseY;
|
||||
element.style.left = offsetX + dX + 'px';
|
||||
element.style.top = offsetY + dY + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
resizable(element, onResize) {
|
||||
let mouseX, mouseY, offset = 0, offsetY = 0, grab = '';
|
||||
|
||||
// mouse button down over the element
|
||||
element.addEventListener('mousedown', onMouseDown);
|
||||
|
||||
element.querySelector('section').addEventListener('mousedown', onBlockMouseDown);
|
||||
|
||||
function onBlockMouseDown(event) { event.stopPropagation(); }
|
||||
/**
|
||||
* mousedown event listener
|
||||
*
|
||||
* @param {Object} event - mouse event.
|
||||
*/
|
||||
function onMouseDown(event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
if(event.currentTarget.hasAttribute('expanded')) return false;
|
||||
|
||||
let limits = element.getBoundingClientRect();
|
||||
|
||||
mouseX = event.clientX;
|
||||
mouseY = event.clientY;
|
||||
|
||||
grab = '';
|
||||
grab += (Math.abs(mouseY - limits.y) < Math.abs(mouseY - (limits.bottom))) ? 'n':'s';
|
||||
grab += (Math.abs(mouseX - limits.x) < Math.abs(mouseX - (limits.right))) ? 'e':'w';
|
||||
|
||||
offset = element.getBoundingClientRect();
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
}
|
||||
|
||||
element.addEventListener('mouseup', onMouseUp);
|
||||
|
||||
/**
|
||||
* mouseup event listener
|
||||
*
|
||||
* @param {Object} event - The event.
|
||||
*/
|
||||
function onMouseUp(event) { document.removeEventListener('mousemove', onMouseMove); }
|
||||
|
||||
/**
|
||||
* Listens to `mousemove` event.
|
||||
*
|
||||
* @param {Object} event - The event.
|
||||
*/
|
||||
function onMouseMove(event) {
|
||||
|
||||
if(event.buttons != 1) {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
return;
|
||||
}
|
||||
|
||||
let dx = event.clientX - mouseX;
|
||||
let dy = event.clientY - mouseY;
|
||||
mouseX = event.clientX;
|
||||
mouseY = event.clientY;
|
||||
|
||||
if(grab.indexOf('n') > -1) {
|
||||
offset.y = offset.y + dy;
|
||||
offset.height = offset.height - dy;
|
||||
}
|
||||
if(grab.indexOf('s') > -1) {
|
||||
offset.height = offset.height + dy;
|
||||
}
|
||||
if(grab.indexOf('e') > -1) {
|
||||
offset.x = offset.x + dx;
|
||||
offset.width = offset.width - dx;
|
||||
}
|
||||
if(grab.indexOf('w') > -1) {
|
||||
offset.width = offset.width + dx;
|
||||
}
|
||||
|
||||
element.style.left = offset.x + 'px';
|
||||
element.style.top = offset.y + 'px';
|
||||
element.style.height = offset.height + 'px';
|
||||
element.style.width = offset.width + 'px';
|
||||
|
||||
onResize();
|
||||
}
|
||||
}
|
||||
|
||||
DOMContentResized() {
|
||||
let limits = [
|
||||
{ name: 'desktop', min: 768 },
|
||||
{ name: 'tablet', min: 600 },
|
||||
{ name: 'mobile', min: 0 }
|
||||
];
|
||||
let bounds = this.el.getBoundingClientRect();
|
||||
let device = limits.find(item => item.min < bounds.width);
|
||||
this.el.setAttribute('device', device ? device.name: limits[0].name);
|
||||
}
|
||||
}
|
||||
|
||||
app.registerClass('EICDomContent', EICDomContent);
|
||||
Executable
+123
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Meta data collections utility class for Sparc
|
||||
*
|
||||
* @author Michael Fallise
|
||||
* @version 1.0
|
||||
* @category MyEic
|
||||
* @subcategory Libraries
|
||||
*/
|
||||
class EICMetaData {
|
||||
|
||||
collections = {};
|
||||
|
||||
/**
|
||||
* Adds a collection to the metadata set
|
||||
* @param {string} ref
|
||||
* @param {array} collection
|
||||
*/
|
||||
add(ref, collection) { if(!this.collections.hasOwnProperty(ref)) this.collections[ref] = collection; }
|
||||
|
||||
/**
|
||||
* Returns a collection
|
||||
* @param {string} ref
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
getCollection(ref) { return this.collections.hasOwnProperty(ref) ? this.collections[ref].content: []; }
|
||||
|
||||
/**
|
||||
* Return a collection item object
|
||||
* @param {*} ref
|
||||
* @param {*} id
|
||||
* @returns {object|null}
|
||||
*/
|
||||
getItem(collection, id) {
|
||||
// if collection is a string ref, fetch matching collection
|
||||
if(!Array.isArray(collection)) collection = this.getCollection(collection);
|
||||
// parse recursively to find matching item id
|
||||
return collection ? this.getItemRecurse(collection, id) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} tree
|
||||
* @param {*} id
|
||||
* @returns {object|null}
|
||||
*/
|
||||
getItemRecurse(tree, id) {
|
||||
|
||||
if (Array.isArray(tree)) {
|
||||
for(let i = 0; i < tree.length; i++) {
|
||||
let item = this.getItemRecurse(tree[i], id);
|
||||
if(item) { return item; }
|
||||
}
|
||||
} else if (typeof tree === 'object') {
|
||||
if (tree.id && tree.id == id) return tree;
|
||||
}
|
||||
if (tree.children !== undefined && tree.children.length > 0) {
|
||||
return this.getItemRecurse(tree.children, id);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a collection item label
|
||||
* @param {string} ref
|
||||
* @param {string} id
|
||||
* @returns {string}
|
||||
*/
|
||||
toString(ref, id) {
|
||||
let item = this.getItem(ref, id);
|
||||
return item ? item.label: `(${ref}.${id})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a collection into a list of OPTION tags
|
||||
* @param {string} ref
|
||||
* @returns {Array<Element>}
|
||||
*/
|
||||
toOptions(collection, selectedIds=[], placeHolder) {
|
||||
if(!Array.isArray(collection)) collection = this.getCollection(collection);
|
||||
if(!Array.isArray(selectedIds)) selectedIds = [selectedIds];
|
||||
let options = [];
|
||||
if(placeHolder) options.push(ui.create(`<option value=""><option>`))
|
||||
|
||||
if(collection)
|
||||
for (const item of collection) {
|
||||
options.push(ui.create(`<option value="${item.id}" ${(selectedIds.indexOf(item.id)>-1) ? 'selected':''}>${item.label}</option>`));
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} collection
|
||||
* @param {*} selectedId
|
||||
* @param {*} placeHolder
|
||||
* @param {*} parentId
|
||||
* @returns {Array<Element>}
|
||||
*/
|
||||
toOptionsRecurse(collection, selectedId, placeHolder, parentId) {
|
||||
if(!Array.isArray(collection)) collection = this.getCollection(collection);
|
||||
let options = [];
|
||||
let children = [];
|
||||
if(placeHolder) options.push(`<option value=""><option>`)
|
||||
|
||||
if(collection)
|
||||
for (const item of collection) {
|
||||
options.push(`<option ${item.children ? `data-ref="${item.id}"`: ''} value="${item.id}" ${selectedId ? (selectedId == item.id ? 'selected':'') :''}>${item.label}</option>`);
|
||||
if(item.children) children.push({parent: item.id, children: item.children})
|
||||
}
|
||||
|
||||
let html = parentId ? `<optgroup data-parent="${parentId}">${options.join('')}</optgroup>`: `${options.join('')}`;
|
||||
for(let item of children) {
|
||||
html += `${this.toOptionsRecurse(item.children, selectedId, false, item.parent)}`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
}
|
||||
|
||||
app.registerClass('EICMetaData',EICMetaData);
|
||||
app.meta = new EICMetaData();
|
||||
Executable
+167
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
base class for data models of the EIC platform
|
||||
|
||||
instanciation requires 2 parameters, businessObject and privileges.
|
||||
- businessObject: a string reference to a set of endpoints provided by the API discovery service (API descriptr can be found in app.config.api)
|
||||
- privileges: an array of endpoints references (aka actions) allowed specifically for a certain resource. Those privileges are provided by the middle tier prior accessing the resource. * @category MyEic
|
||||
* @category MyEic
|
||||
* @subcategory Libraries
|
||||
* @extends Model
|
||||
*/
|
||||
class EICModel extends Model {
|
||||
|
||||
// data container for the item
|
||||
itemData = {};
|
||||
// list of available api services for this model
|
||||
api = {};
|
||||
// privileges granted on this model
|
||||
privileges = [];
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} resource A string reference to the resource (aka business obkect) to be handled (ex: "users" or "project")
|
||||
* @param {array} privileges An array of allowed privileges (as string reference. ex: "save", "delete", etc...). Privileges should match a corresponding method in the controller
|
||||
*/
|
||||
constructor(resource, privileges) {
|
||||
super();
|
||||
|
||||
// if((app.config.hasOwnProperty('api')) && (app.config.api.hasOwnProperty(resource))) {
|
||||
// this.api = app.config.api[resource];
|
||||
// } else this.config.api = {}
|
||||
|
||||
this.setPrivileges(resource, privileges);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} action
|
||||
* @param {*} resource
|
||||
* @returns {object}
|
||||
*/
|
||||
getApiEndpoint(action, resource=null) {
|
||||
resource = resource || this.resource;
|
||||
for(let ApiBizObj of Object.keys(app.config.api)) {
|
||||
|
||||
//If called without real ids
|
||||
if(ApiBizObj==resource) return(JSON.parse(JSON.stringify(app.config.api[ApiBizObj][action])));
|
||||
|
||||
//If called with real ids
|
||||
let re = new RegExp('^'+ApiBizObj.replace(/\//g,'\\/').replace(/\{\w+\}/gi,'([0-9a-zA-Z]+)')+'$', 'm')
|
||||
if(resource.match(re)) {
|
||||
if(app.config.api[ApiBizObj].hasOwnProperty(action)) return(JSON.parse(JSON.stringify(app.config.api[ApiBizObj][action])));
|
||||
else console.error(`No API endpoint for action "${action}" on resource "${resource}"`)
|
||||
}
|
||||
}
|
||||
console.error(`No API service for resource:${resource}`)
|
||||
return({ "method":null, "uri": null });
|
||||
}
|
||||
|
||||
setPrivileges(resource, privileges) {
|
||||
this.resource = resource;
|
||||
this.privileges = privileges || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a privilege is available for this profile
|
||||
* @param {string} privilege ref
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasPrivilege(privilege) {
|
||||
if(this.privileges.indexOf(privilege) != -1) return(true);
|
||||
else {
|
||||
//console.warn('You do not have the privilege:',privilege);
|
||||
return(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the model item properties with provided object
|
||||
* @param {object} data
|
||||
* @returns {object}
|
||||
*/
|
||||
async loadData(data) {
|
||||
// itemData should be cleared first
|
||||
this.itemData = {}
|
||||
Object.assign(this.itemData, data); // this way getters are natural
|
||||
return this.itemData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a stringified JSON output of the itemData object
|
||||
* Practical for cloning the model data
|
||||
* @returns {string}
|
||||
*/
|
||||
toString() { return(JSON.stringify(this)); }
|
||||
|
||||
/**
|
||||
* Deep copies updates object into target
|
||||
* @param {object} target
|
||||
* @param {object} updates
|
||||
* @returns {object}
|
||||
*/
|
||||
merge(current, updates) {
|
||||
for (let key of Object.keys(updates)) {
|
||||
if (!current.hasOwnProperty(key) || typeof updates[key] !== 'object') current[key] = updates[key];
|
||||
else this.merge(current[key], updates[key]);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current item id. Shortcut for itemData.id
|
||||
* @returns {string}
|
||||
*/
|
||||
get id() { return(this.itemData.id); }
|
||||
|
||||
/**
|
||||
* Tries to match the instance with given data
|
||||
*
|
||||
* @param {Object} matchValues - { {string} key: {string} valueToMatch }
|
||||
* @param {Object} matchExact - { strin{key}: {Boolean} } Defaults to exact match if parameter absent or if key absent.
|
||||
* Non-exact match uses valueToMatch as a REGEX. valueToMatch is then a string like "/regex/" or "/regex/flags".
|
||||
* @return {boolean} true if match
|
||||
*/
|
||||
match(matchValues, matchExact = {}){
|
||||
for(let key in matchValues){
|
||||
let val = matchValues[key];
|
||||
if((key in matchExact) && (matchExact[key])){ // equality
|
||||
if(this.itemData[key]!=val) return(false);
|
||||
} else { //Regex
|
||||
let re = new RegExp(val.substring(1, val.lastIndexOf('/')), val.substring(val.lastIndexOf('/')+1));
|
||||
if(!re.test(this.itemData[key])) return(false);
|
||||
}
|
||||
}
|
||||
return(true);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} error
|
||||
* @param {number} error.code
|
||||
* @param {string} error.displayMessage
|
||||
*/
|
||||
onRequestError(error) {
|
||||
if([400,404,405,409].indexOf(error.code) > -1) {
|
||||
console.warn('Ajax request error:', error);
|
||||
ui.growl.append(`${error.displayMessage}`, 'warning', 10000);
|
||||
} else if(error.code > 499) {
|
||||
console.error('Ajax request error:', error);
|
||||
ui.growl.append(
|
||||
`Unfortunately, there was a server error.<br>
|
||||
Please contact the helpdesk if it persists
|
||||
`, 'danger', 5000);
|
||||
} else if(error.code==403){
|
||||
console.warn(`Ajax request unauthorized in ${this.constructor.name}`, error);
|
||||
ui.growl.append(`${error.displayMessage}`, 'danger', 10000);
|
||||
} else if((error.code==401) && (app.User.isAuthenticated)){
|
||||
console.warn(`Unauthenticated in ${this.constructor.name}`, error);
|
||||
app.User.isAuthenticated = false
|
||||
app.User.stopKeepAlive()
|
||||
app.Router.route('/401', {'triggerUrl' :app.Router.currentRoute.realUrl } );
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
app.registerClass('EICModel',EICModel);
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* @author Nicolas Stein
|
||||
* @category MyEic
|
||||
* @subcategory Libraries
|
||||
* @extends EICModel
|
||||
*/
|
||||
class EICPluralModel extends EICModel {
|
||||
|
||||
constructor(businessObject, privileges, singletonClass) {
|
||||
super(businessObject, privileges);
|
||||
|
||||
this.collection = [];
|
||||
//this.api = app.config.api.filter(item => item.businessObject == businessObject);
|
||||
this.singletonClass = singletonClass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all ids in the current collection
|
||||
* @return {Array} Ids (keys) of the current collection.
|
||||
*/
|
||||
get ids() { return this.collection.map(item => item.itemData.id); }
|
||||
|
||||
|
||||
/**
|
||||
* Creates empty singletons at the end of the collection.
|
||||
* @param {Array} ids - Ids of the singletons to create
|
||||
*/
|
||||
create(ids){
|
||||
for(let id of ids){
|
||||
this.collection[id] =new this.singletonClass;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the data in a singleton & adds it to the collection.
|
||||
* If the id exists, will just override keys from the server (preserving eventual overrides)
|
||||
* @param {Object} data - key-value data used to create the singleton.
|
||||
* @return {Object} The collection length after insertion.
|
||||
*/
|
||||
add(data, id=null){
|
||||
id = data.id || null;
|
||||
let item = this.collection.find(item => item.id == id);
|
||||
if(item){
|
||||
Object.assign(item.itemData, data);
|
||||
} else {
|
||||
item = new this.singletonClass();
|
||||
item.itemData = data;
|
||||
this.collection.push(item);
|
||||
}
|
||||
return(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes and returns a singleton out of the collection.
|
||||
* @param {string} id - id of the singleton to pop.
|
||||
* @return {Object} The popped singleton.
|
||||
*/
|
||||
pop(id) {
|
||||
|
||||
if(id in this.collection) {
|
||||
let singleton = this.collection[id];
|
||||
this.delete(this.collection[id]);
|
||||
return(singleton);
|
||||
} else return(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of singletons in the collection.
|
||||
* @return {Int} Number of singletons.
|
||||
*/
|
||||
get length() { return this.collection.length; }
|
||||
/**
|
||||
* Empties the collection
|
||||
*/
|
||||
empty() { this.collection = []; }
|
||||
|
||||
/**
|
||||
* Search the collection for matching singletons
|
||||
* @param {Object} matchValues - { {string} key: {string} valueToMatch }
|
||||
* @param {Object} matchExact - { strin{key}: {Boolean} } Defaults to exact match if parameter absent or if key absent
|
||||
* @return {Array} Array of matching singletons.
|
||||
*/
|
||||
localFind(matchValues, matchExact){
|
||||
return(
|
||||
this.collection.filter((singleton) => singleton.match(matchValues, matchExact))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads singletons from their IDs at the end of the current collection.
|
||||
* if no ids given, loads all
|
||||
* Expected server-contract:
|
||||
* [ {"id":"xxx", ...other singleton data...}, {"id":"yyy", ...other singleton data...}, ...]
|
||||
* @param {array} ids - singleton ids to be loaded
|
||||
* @return {promise} Promise returning the model
|
||||
*/
|
||||
load(endpoint, payload){
|
||||
return(
|
||||
this.request(endpoint.uri, endpoint.method, payload)
|
||||
.then(async serverData => {
|
||||
this.fill(serverData);
|
||||
return(this);
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
fill(serverData) {
|
||||
if(serverData && serverData.length){
|
||||
for(let singletonData of serverData) this.add(this.sanitize(singletonData));
|
||||
}
|
||||
}
|
||||
|
||||
sanitize(item) { return item; }
|
||||
|
||||
/**
|
||||
* Loads singletons from search criteria at the end of the current collection.
|
||||
* @param {Object} matchValues - { {string} key: {string} valueToMatch }
|
||||
* @param {Object} matchExact - { strin{key}: {Boolean} } Defaults to exact match if parameter absent or if key absent
|
||||
* @return {Array} Array of matching singletons.
|
||||
*/
|
||||
remotefind(matchValues, matchExact){
|
||||
return(
|
||||
this.post(this.baseUrl+'search', { 'matchValues': matchValues, 'matchExact': matchExact })
|
||||
.then(async serverData => {
|
||||
if(serverData){
|
||||
for(let singletonData of serverData) this.add(singletonData);
|
||||
}
|
||||
return(this);
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
save(){
|
||||
return(
|
||||
this.put(this.baseUrl, this.collection)
|
||||
.then(async serverData => {
|
||||
if(serverData){
|
||||
for(let singletonData of data) this.add(singletonData);
|
||||
}
|
||||
return(this);
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fills the collection from a raw CSV string.
|
||||
* (adapted from https://stackoverflow.com/users/10549827/matthew-e-brown)
|
||||
* @param {string} csv The raw CSV string.
|
||||
* @param {string[]} headers An optional array of headers to use. If none are
|
||||
* given, they are pulled from the first line of `text`.
|
||||
* @param {string} idName The name of the column to be used as id.
|
||||
* @param {string} quoteChar A character to use as the encapsulating character.
|
||||
* @param {string} delimiter A character to use between columns.
|
||||
*/
|
||||
//TODO NIKE: test ! & understand why in accumulator, merge uses [key] instead of key
|
||||
fromCsv(csv, headers = null, idName='id', quoteChar = '"', delimiter = ',') {
|
||||
const regex = new RegExp(`\\s*(${quoteChar})?(.*?)\\1\\s*(?:${delimiter}|$)`, 'gs');
|
||||
const match = line => [...line.matchAll(regex)]
|
||||
.map(m => m[2]) // we only want the second capture group
|
||||
.slice(0, -1); // cut off blank match at the end
|
||||
|
||||
const lines = csv.split('\n');
|
||||
const firstLine = lines.shift();
|
||||
const heads = headers || match(firstLine);
|
||||
if(heads.indexOf(idName)<0) {
|
||||
console.warn(`id column "${idName}" not found in CSV headers !`);
|
||||
return;
|
||||
}
|
||||
heads[heads.indexOf(idName)]='id'; let cols;
|
||||
for(let line of lines){
|
||||
cols = match(line);
|
||||
if(cols.length>=heads.length){
|
||||
let singletonData = cols.reduce((acc, cur, i) => {
|
||||
// Attempt to parse as a number; replace blank matches with `null`
|
||||
const val = cur.length <= 0 ? null : Number(cur) || cur;
|
||||
const key = heads[i] [[]] `extra_${i}`;
|
||||
return { ...acc, [key]: val };
|
||||
}, {});
|
||||
this.add(singletonData);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a CSV in a string from the collection.
|
||||
* @param {Array} keys model properties you want to include in the csv. It defaults to the least common denominator of properties across the collection.
|
||||
* @param {boolean} withHeaders If true, the first line has the properties names.
|
||||
* @param {string} quoteChar Will surround all values (and properties if withHeader).
|
||||
* @param {string} delimiter A character to use between columns.
|
||||
*/
|
||||
//TODO NIKE: test !
|
||||
toCsv(keys=null, withHeader=true, quoteChar = '"', delimiter = ';', downloadName=null){
|
||||
const _dwnldCsv = function(data, fname) {
|
||||
const blob = new Blob([data], { type: 'text/csv' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.setAttribute('href', url);
|
||||
a.setAttribute('download', fname+'.csv');
|
||||
a.click();
|
||||
}
|
||||
if(!keys) { // take min. common set of keys in collection (take 1st then successively intersect to itself)
|
||||
let ids = Object.keys(this.collection);
|
||||
keys = Object.keys(this.collection[ids[0]].itemData);
|
||||
for(let id of ids) {
|
||||
keys = keys.filter(x => (this.collection[id].itemData.hasOwnProperty(x)))
|
||||
}
|
||||
}
|
||||
let csv=''; let row=[];
|
||||
if(withHeader===true){
|
||||
for(let key of keys){
|
||||
row.push(quoteChar+key+quoteChar);
|
||||
}
|
||||
csv += row.join(delimiter)+'\n';
|
||||
} else if(Array.isArray(withHeader)) {
|
||||
for(let title of withHeader){
|
||||
row.push(quoteChar+title+quoteChar);
|
||||
}
|
||||
csv += row.join(delimiter)+'\n';
|
||||
}
|
||||
for(let id in this.collection){
|
||||
row = [];
|
||||
for(let key of keys){
|
||||
let item = ''
|
||||
if(typeof(key)=='string'){ // normal column
|
||||
item = this.collection[id].itemData
|
||||
for(let k of key.split('.')) { // Allow for sub-objects with dotted keys notation
|
||||
if(!item) break
|
||||
item = item[k]
|
||||
}
|
||||
} else if(typeof(key)=='function') { // computed column
|
||||
item = key(this.collection[id].itemData)
|
||||
} else console.warn('CSV: Bad column: ',key)
|
||||
row.push(quoteChar+item+quoteChar);
|
||||
}
|
||||
csv += row.join(delimiter)+'\n';
|
||||
}
|
||||
if(downloadName) _dwnldCsv(csv, downloadName)
|
||||
return(csv);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
app.registerClass('EICPluralModel',EICPluralModel);
|
||||
@@ -0,0 +1,115 @@
|
||||
class FakeFileSystem {
|
||||
|
||||
constructor() {
|
||||
this.currentPath='/'
|
||||
this.path2dir = {}
|
||||
}
|
||||
|
||||
/*
|
||||
loadStructure : loads an object like dirname: [ subojbects ]
|
||||
*/
|
||||
loadStructure(struct, files) {
|
||||
this._scanStruct('/', struct, files || []);
|
||||
}
|
||||
|
||||
_scanStruct(locPath, struct, files){
|
||||
this.path2dir[locPath]={ /* assign local files & local folers*/
|
||||
files: [...files.filter(item=>{
|
||||
let filePath = (item.fullPath.split('/').length>2) ? item.fullPath.substr(0,item.fullPath.lastIndexOf('/')) : '/'
|
||||
return(filePath==locPath)
|
||||
})],
|
||||
folders: [...Object.keys(struct).map(item=> {
|
||||
let folderPath = (locPath=='/') ? '/'+item: locPath+'/'+item
|
||||
let childrenFiles = files.filter(item=>(item.object.path==folderPath))
|
||||
return({
|
||||
name:item,
|
||||
path: folderPath,
|
||||
isEmpty:( (Object.keys(struct[item]).length==0) && (childrenFiles.length==0) )
|
||||
})
|
||||
})]
|
||||
}
|
||||
if(locPath!='/') this.path2dir[locPath].folders.unshift({name:'..', path:'..'})
|
||||
|
||||
this.path2dir[locPath].files.sort((a,b)=>((a.object.name<b.object.name) ? -1 : 1))
|
||||
this.path2dir[locPath].folders.sort((a,b)=>((a.name<b.name) ? -1 : 1))
|
||||
for(let folderName of Object.keys(struct)){ // Now recur for each subfolder
|
||||
let subPath = (locPath=='/') ? '/'+folderName : locPath+'/'+folderName
|
||||
let childrenFiles = files.filter(item=>(item.fullPath.length>subPath.length))
|
||||
this._scanStruct(
|
||||
subPath,
|
||||
struct[folderName],
|
||||
childrenFiles
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
getFolder : returns an alphabetically sorted array of folders at the current path
|
||||
*/
|
||||
getFolders(){
|
||||
return(this.path2dir[this.currentPath].folders)
|
||||
}
|
||||
|
||||
/*
|
||||
getFiles : returns an alphabetically sorted array of files at the current path
|
||||
*/
|
||||
getFiles(){
|
||||
return(this.path2dir[this.currentPath].files)
|
||||
}
|
||||
|
||||
/*
|
||||
findFile : returns the path of the first file than validates the comparison function.
|
||||
The comparison function is given the file object as parameter, and should return a boolean.
|
||||
*/
|
||||
findFile(fn){
|
||||
for(let path of Object.keys(this.path2dir)){
|
||||
for(let file of this.path2dir[path].files){
|
||||
if(fn(file.object)) return(path)
|
||||
}
|
||||
}
|
||||
return(false)
|
||||
}
|
||||
|
||||
/*
|
||||
changeDir : changes the current path, relatively or absolutely
|
||||
*/
|
||||
changeDir(path){
|
||||
if(path.startsWith('/')){ //Absolute
|
||||
if(this.pathExists(path)) this.currentPath = path
|
||||
} else { //relative
|
||||
let newPath = this.normalizePath(this.currentPath+'/'+path)
|
||||
if(newPath=='') newPath='/'
|
||||
if(this.pathExists(newPath)) this.currentPath = newPath
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
pathExists : Check if path (file excluded) exists in the structure
|
||||
*/
|
||||
pathExists(path){
|
||||
return(Object.keys(this.path2dir).includes(path))
|
||||
}
|
||||
|
||||
/*
|
||||
normalizePath : cleans up and normalizes (executes all /../)
|
||||
*/
|
||||
normalizePath(path) {
|
||||
path = path.replace(/\/+/g, '/')
|
||||
if (path.startsWith("/")) path = path.substring(1)
|
||||
if (path.endsWith("/")) path = path.slice(0, -1)
|
||||
let segments = path.split("/")
|
||||
let normalizedPath = "/"
|
||||
for(let segment of segments) {
|
||||
if (segment === "." || segment === "") continue
|
||||
if (segment === "..") {
|
||||
normalizedPath = normalizedPath.substring(0, normalizedPath.lastIndexOf("/") )
|
||||
continue
|
||||
}
|
||||
if (!normalizedPath.endsWith("/")) normalizedPath = normalizedPath + "/"
|
||||
normalizedPath = normalizedPath + segment
|
||||
}
|
||||
return(normalizedPath)
|
||||
}
|
||||
|
||||
}
|
||||
app.registerClass('FakeFileSystem', FakeFileSystem)
|
||||
Executable
+352
@@ -0,0 +1,352 @@
|
||||
'use strict'
|
||||
/**
|
||||
__ __
|
||||
( )( ) ___ ____ ____
|
||||
)( )( / __)( ___)( _ \
|
||||
)(__)( \__ \ )__) ) /
|
||||
(______)(___/(____)(_)\_)
|
||||
By Nike
|
||||
|
||||
This file is part of EIC implementation of SPARC.
|
||||
* @category MyEic
|
||||
* @subcategory Libraries
|
||||
* @extends User
|
||||
*/
|
||||
class myUser extends app.LoadedClasses.User {
|
||||
|
||||
authUrl = '';
|
||||
preferences = {}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
constructor() { super(); }
|
||||
|
||||
/**
|
||||
* Checks if user belongs to a role
|
||||
* @param {string} role
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasRole(role) { return(this.roles.indexOf(role)>-1); }
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {Array<string>}
|
||||
*/
|
||||
getRoles() { return(app.User.roles); }
|
||||
|
||||
/**
|
||||
* @async
|
||||
* @returns {string}
|
||||
*
|
||||
*/
|
||||
fetchServices() {
|
||||
let host = new URL(app.config.userLib.apiDiscoveryEndpoint).host
|
||||
let stage = (host.split('.')[1] != 'eismea') ? '.'+host.split('.')[1] : ''
|
||||
|
||||
return(fetch('/app/assets/json/global/services.json?'+crypto.randomUUID(), {
|
||||
method: 'GET'
|
||||
})
|
||||
.then(response=>response.text())
|
||||
.then(response=>JSON.parse(response.replace(/__host__/g, host).replace(/__stage__/g, stage)))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate for deprecation
|
||||
* @returns {Promise}
|
||||
*/
|
||||
getApiServices() {
|
||||
return(
|
||||
this.fetchServices()
|
||||
.then(response => {
|
||||
if(response.success) {
|
||||
// Was to much to ask to respect existing code & existing contract, so do the cleanup here
|
||||
let api = {};
|
||||
for(let entry of response.payload){
|
||||
api[entry.resource] = entry.actions.reduce( (acc, v)=>{
|
||||
acc[v.action]=v.availableMethod;
|
||||
return(acc); }, {}
|
||||
);
|
||||
}
|
||||
|
||||
// Now override with exceptions from config. (also creates from exceptions)
|
||||
for(let resource in app.config.userLib.apiStageExceptions){
|
||||
if(!api.hasOwnProperty(resource)) api[resource] = [];
|
||||
for(let action in app.config.userLib.apiStageExceptions[resource]) {
|
||||
api[resource][action] = app.config.userLib.apiStageExceptions[resource][action];
|
||||
console.warn(`Replacing / adding existing API with exception for resource: ${resource} action: ${action}`)
|
||||
}
|
||||
}
|
||||
app.config.api = api;
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} callBack
|
||||
*/
|
||||
checkAuthenticated(callBack){
|
||||
let headers = {};
|
||||
if(app.config.userLib.authForwardDomain) {
|
||||
let url = new URL(document.location.href);
|
||||
headers = { 'x-requested-path': url.pathname };
|
||||
}
|
||||
|
||||
this.identity = {
|
||||
uuid: 'nike',
|
||||
email: 'info@nicsys.eu'
|
||||
};
|
||||
this.roles = ['admin']
|
||||
return
|
||||
|
||||
fetch(app.config.userLib.authEndpoint+'?'+crypto.randomUUID(),{
|
||||
headers: headers,
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(async resp => {
|
||||
if(resp.success){
|
||||
this.authenticationDone = true
|
||||
this.isAuthenticated = resp.payload.isAuthenticated;
|
||||
if(resp.payload.isAuthenticated) {
|
||||
if((!this.identity) || (!this.identity.uuid)) {
|
||||
this.logoutUrl = resp.payload.logoutUri;
|
||||
this.parseUserInfo(resp.payload.userInfo);
|
||||
}
|
||||
|
||||
this.platformRestrictions = resp.payload.platformRestrictions || null
|
||||
if((this.platformRestrictions) && (!this.isVIP())){
|
||||
this.ShowCurtain()
|
||||
this.stopKeepAlive()
|
||||
return // not triggering callback avoids any further ctrl loading by the router
|
||||
}
|
||||
|
||||
if(!app.config.api) await this.getApiServices()
|
||||
|
||||
if(app.config.userLib.keepAliveSeconds && (app.config.userLib.keepAliveSeconds>0)) this.startKeepAlive()
|
||||
|
||||
callBack();
|
||||
} else {
|
||||
console.warn('Authorizer said User was not authenticated !');
|
||||
this.authUrl = resp.payload.authUrl;
|
||||
this.logoutUrl = resp.payload.logoutUri;
|
||||
callBack();
|
||||
}
|
||||
} else {
|
||||
console.error('Server error calling authorizer checkAuthenticated (success not true)');
|
||||
this.stopKeepAlive() // Just in case KAL is active, because we arrive here from KAL itself
|
||||
document.location.href = '/eulogin-error.html';
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Server error calling authorizer checkAuthenticated (Network error)',err);
|
||||
document.location.href = '/eulogin-error.html';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
getMessageBusUserInfo() { return(this.identity.uuid) }
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} objects An array of URI to be checked
|
||||
* @param {*} role
|
||||
* @returns {Promise}
|
||||
*/
|
||||
getBusinessPermissions(objects, role) {
|
||||
let requestedObjects = { resources: objects }
|
||||
let uri = app.config.userLib.resourcePermissionsEndpoint.replace('{id}', this.identity.uuid);
|
||||
|
||||
// This is just to make use of the whole base model request mechanism (especially for the 401 flow)
|
||||
let fakeModel = new EICModel(null, null)
|
||||
return(
|
||||
fakeModel.request(uri, 'POST', requestedObjects)
|
||||
.then( async serverData => {
|
||||
return(serverData.payload)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} jumpTo
|
||||
*/
|
||||
logout(jumpTo='') {
|
||||
jumpTo = jumpTo ? jumpTo : this.logoutUrl
|
||||
fetch(app.config.userLib.logoutEndpoint+'?'+crypto.randomUUID(),{
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
}).then((resp) =>{
|
||||
window.onbeforeunload = null // If user confirmed to logout, not need to have him confirm he's leaving the app !
|
||||
document.location.href = jumpTo;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* This is separated, so that upper layer has a chance to use a login button, or avoid redirection loops.
|
||||
*/
|
||||
gotoLogin() {
|
||||
window.onbeforeunload = null // If user asks to relogin, not need to have him confirm he's leaving the app !
|
||||
document.location.href = this.authUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
startKeepAlive() {
|
||||
if((!app.config.userLib.keepAliveSeconds) || this.KALtimer) return
|
||||
this.KALtimer = setInterval(this.doKeepAlive.bind(this), 1000*app.config.userLib.keepAliveSeconds)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
stopKeepAlive() {
|
||||
if(this.KALtimer) clearInterval(this.KALtimer)
|
||||
this.KALtimer = null
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
doKeepAlive() {
|
||||
this.checkAuthenticated(()=>{
|
||||
// KAL was successfull... do we care ?
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isVIP(){
|
||||
if(Array.isArray(this.platformRestrictions.allowedRoles)){
|
||||
let intersect = this.roles.filter(r => (this.platformRestrictions.allowedRoles.includes(r)) )
|
||||
if(intersect.length>0) return(true)
|
||||
}
|
||||
if((Array.isArray(this.platformRestrictions.allowedUUIDs)) &&
|
||||
(this.platformRestrictions.allowedUUIDs.includes(this.identity.uuid))){
|
||||
return(true)
|
||||
}
|
||||
return(false)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @todo should be handled by an external view
|
||||
*/
|
||||
ShowCurtain() {
|
||||
let link = document.createElement("link");
|
||||
link.rel='stylesheet'
|
||||
link.type='text/css'
|
||||
link.href='/app/thirdparty/eicui/eicui-2.0.css'
|
||||
document.head.appendChild(link)
|
||||
|
||||
let style = document.createElement('style')
|
||||
style.innerHTML = ` .maintenance{ width: 40vw!important; text-align: center; margin: auto!important; } `
|
||||
document.head.appendChild(style)
|
||||
|
||||
let content = document.createElement('div')
|
||||
content.innerHTML = `
|
||||
<article class="maintenance" eiccard="" aria-enabled="true" >
|
||||
<header><h1>Maintenance</h1></header>
|
||||
<section>
|
||||
<alert eicalert="" danger="">
|
||||
The site is currently under maintenance.</br>
|
||||
Please come back later.
|
||||
</alert>
|
||||
</section>
|
||||
</article>
|
||||
`
|
||||
document.body.appendChild(content)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} info
|
||||
*/
|
||||
async parseUserInfo(info) {
|
||||
|
||||
this.identity = {
|
||||
uuid: info.euLoginId,
|
||||
firstname: info.family_name,
|
||||
lastname: info.given_name,
|
||||
email: info.email
|
||||
};
|
||||
this.roles = info.userRoles || []
|
||||
}
|
||||
|
||||
loadPreferences() {
|
||||
if(app.MessageBus) {
|
||||
app.MessageBus.requestWssGwAction('GET', { key: `${this.identity.uuid}:userPrefs`})
|
||||
.then(settings => {
|
||||
this.preferences = settings.value;
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
savePreferences() {
|
||||
if(app.MessageBus) {
|
||||
app.MessageBus.requestWssGwAction('SET', {
|
||||
key: `${this.identity.uuid}:userPrefs`,
|
||||
value: this.preferences
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
getPreference(path) {
|
||||
let value = null;
|
||||
|
||||
if(app.MessageBus) {
|
||||
let segments = path.split('.');
|
||||
let pointer = this.preferences;
|
||||
if(pointer) {
|
||||
for(let segment of segments) {
|
||||
if(pointer[segment]) {
|
||||
if(typeof pointer[segment] == 'object') {
|
||||
pointer = pointer[segment];
|
||||
} else {
|
||||
value = pointer[segment];
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
setPreference(path, value) {
|
||||
if(app.MessageBus) {
|
||||
let segments = path.split('.');
|
||||
let pointer = this.preferences;
|
||||
|
||||
for(let i = 0; i < segments.length - 1; i++) {
|
||||
let segment = segments[i];
|
||||
if(!pointer[segment]) {
|
||||
pointer[segment] = {};
|
||||
}
|
||||
pointer = pointer[segment];
|
||||
}
|
||||
|
||||
pointer[segments[segments.length - 1]] = value;
|
||||
}
|
||||
|
||||
this.savePreferences();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
app.registerClass('User', myUser, true); // for Sparc to use
|
||||
app.registerClass('myUser', myUser, true); // Just to avoid double-loading if squeezed
|
||||
Reference in New Issue
Block a user