welcome to Windoz

This commit is contained in:
STEINNI
2025-10-14 16:31:07 +00:00
parent 8005e31931
commit fdefe42146
24 changed files with 79 additions and 88 deletions
+51
View File
@@ -0,0 +1,51 @@
/**
* @category MyEic
* @subcategory Libraries
* @extends WindozModel
*/
class WindozBusModel extends WindozModel{
/**
* 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('WindozBusModel', WindozBusModel)
+377
View File
@@ -0,0 +1,377 @@
/**
* @category MyEic
* @subcategory Libraries
* @extends Controller
*/
class WindozController 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 settingsMarkup = ''
if(options.withSettings){
settingsMarkup=`
<div eicdropdown>
<button eicbutton data-id="${view._sparcId}" basic primary rounded xsmall class="icon-cog settings" title="settings"></button>
<menu eicmenu data-output="settingsMenu"></menu>
</div>
`
}
let content = ui.create(`<div class="window">
<header class="cols-2 right">
<h1>${options.title || ''}</h1>
<div class="controls">
${settingsMarkup}
<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 class="handle" data-side="nw"></div>
<div class="handle" data-side="n"></div>
<div class="handle" data-side="ne"></div>
<div class="handle" data-side="e"></div>
<div class="handle" data-side="se"></div>
<div class="handle" data-side="s"></div>
<div class="handle" data-side="sw"></div>
<div class="handle" data-side="w"></div>
</div>`);
if(options.expanded) content.setAttribute('expanded','');
if(options.windowStyle){
for(const k in options.windowStyle){
content.style[k] = options.windowStyle[k]
}
}
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));
if(options.withSettings && (typeof(view.settings) == 'function')){
let settings = content.querySelector('header button.settings');
settings.addEventListener('click', view.settings.bind(view));
}
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 WindozDialogContent();
view.el = message;
}
let dialog = WindozController.createDialog(view);
function commit(result) {
WindozController.closeDialog(view._sparcId);
resolve(result);
}
function abort(result) {
WindozController.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');
ui.show(content.view.el, 'flex');
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('WindozController', WindozController);
+76
View File
@@ -0,0 +1,76 @@
/**
* @category MyEic
* @subcategory Libraries
* @extends View
*/
class WindozDialogContent 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('WindozDialogContent', WindozDialogContent);
+295
View File
@@ -0,0 +1,295 @@
/**
* @category MyEic
* @subcategory Libraries
* @extends View
*/
class WindozDomContent extends View {
icon = null;
/**
*
*/
loadContent(name, options, data) { return this._controller.loadContent(name, options, data); }
get expanded() { return this.el.hasAttribute('expanded'); }
_saveWindowBox(){
const box = this.el.getBoundingClientRect()
this.savedWindowBox = {
width: `${box.width}px`,
height: `${box.height}px`,
left: `${box.x}px`,
top: `${box.y}px`,
}
}
/**
*
*/
expand() {
this._saveWindowBox()
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'));
Object.assign(this.el.style, this.savedWindowBox)
// 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 WindozDialogContent();
view.el = content;
}
let dialog = WindozController.createDialog(view);
function commit(result) {
WindozController.closeDialog(view._sparcId);
resolve(result);
}
function abort(result) {
WindozController.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.bind(this));
/**
* 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.bind(this));
}
/**
* 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);
element.removeEventListener('mouseup', onMouseUp);
if(this.windowPrefsId){
const box = element.getBoundingClientRect()
app.User.setPreference(`windows.${this.windowPrefsId}`, {
x: box.x,
y: box.y,
});
}
}
/**
* 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 = '';
element.querySelectorAll(':scope > .handle').forEach(handle => handle.addEventListener('mousedown', onMouseDown.bind(this)))
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.parentElement.hasAttribute('expanded')) return false;
let limits = element.getBoundingClientRect();
mouseX = event.clientX;
mouseY = event.clientY;
grab = event.currentTarget.dataset.side
offset = element.getBoundingClientRect();
document.addEventListener('mousemove', onMouseMove);
element.addEventListener('mouseup', onMouseUp.bind(this));
}
/**
* mouseup event listener
*
* @param {Object} event - The event.
*/
function onMouseUp(event) {
document.removeEventListener('mousemove', onMouseMove);
element.removeEventListener('mouseup', onMouseUp);
if(this.windowPrefsId){
const box = element.getBoundingClientRect()
app.User.setPreference(`windows.${this.windowPrefsId}`, {
w: box.width,
h: box.height,
});
}
}
/**
* 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('w') > -1) {
offset.x = offset.x + dx;
offset.width = offset.width - dx;
}
if(grab.indexOf('e') > -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);
}
static boxFromPrefs(viewName, defaults){
function getPref(path) {
return path.split('.').reduce(
(acc, key) => acc?.[key],
app.User.preferences.windows
)
}
let top, left, width, height
const box = getPref(viewName)
if(box){
left = box.x ? box.x : defaults.x
top = box.y ? box.y : defaults.y
width = box.w ? box.w : defaults.w
height = box.x ? box.h : defaults.h
} else {
left = defaults.x
top = defaults.y
width = defaults.w
height = defaults.h
}
return({
width: `${width}px`,
height: `${height}px`,
left: `${left}px`,
top: `${top}px`,
})
}
}
app.registerClass('WindozDomContent', WindozDomContent);
+123
View File
@@ -0,0 +1,123 @@
/**
* Meta data collections utility class for Sparc
*
* @author Michael Fallise
* @version 1.0
* @category MyEic
* @subcategory Libraries
*/
class WindozMetaData {
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('WindozMetaData',WindozMetaData);
app.meta = new WindozMetaData();
+143
View File
@@ -0,0 +1,143 @@
/**
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 WindozModel 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);
}
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('WindozModel',WindozModel);
+246
View File
@@ -0,0 +1,246 @@
/**
* @author Nicolas Stein
* @category MyEic
* @subcategory Libraries
* @extends WindozModel
*/
class WindozPluralModel extends WindozModel {
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('WindozPluralModel',WindozPluralModel);