#659 - Unify model beta

This commit is contained in:
Juan 2018-10-18 09:24:20 +02:00
parent 7933607dd0
commit ee73068e9e
27 changed files with 951 additions and 574 deletions

View File

@ -37,7 +37,7 @@
id="claimDestinationFk"
field="saleClaimed.claimDestinationFk"
url="/claim/api/ClaimDestinations"
select-fields="['id','description']"
fields="['id','description']"
value-field="id"
show-field="description"
on-change="$ctrl.setClaimDestination(saleClaimed.id, value)">

View File

@ -24,7 +24,7 @@
disabled="true"
field="$ctrl.claim.workerFk"
url="/client/api/Workers"
select-fields="['firstName', 'name']"
fields="['firstName', 'name']"
value-field="id"
label="Worker">
<tpl-item>{{firstName}} {{name}}</tpl-item>

View File

@ -49,7 +49,7 @@
id="claimReason"
field="claimDevelopment.claimReasonFk"
data="claimReasons"
select-fields="['id','description']"
fields="['id', 'description']"
show-field="description"
vn-acl="salesAssistant">
</vn-autocomplete>
@ -59,7 +59,7 @@
id="claimResult"
field="claimDevelopment.claimResultFk"
data="claimResults"
select-fields="['id','description']"
fields="['id', 'description']"
show-field="description"
vn-acl="salesAssistant">
</vn-autocomplete>
@ -69,7 +69,7 @@
id="Responsible"
field="claimDevelopment.claimResponsibleFk"
data="claimResponsibles"
select-fields="['id','description']"
fields="['id', 'description']"
show-field="description"
vn-acl="salesAssistant">
</vn-autocomplete>
@ -88,7 +88,7 @@
id="redelivery"
field="claimDevelopment.claimRedeliveryFk"
data="claimRedeliveries"
select-fields="['id','description']"
fields="['id', 'description']"
show-field="description"
vn-acl="salesAssistant">
</vn-autocomplete>

View File

@ -15,7 +15,7 @@
vn-acl="administrative, salesAssistant"
field="$ctrl.client.payMethodFk"
url="/client/api/PayMethods"
select-fields="ibanRequired"
fields="['ibanRequired']"
initial-data="$ctrl.client.payMethod">
</vn-autocomplete>
<vn-textfield

View File

@ -14,7 +14,7 @@
vn-id="sampleType"
field="$ctrl.clientSample.typeFk"
model="ClientSample.typeFk"
select-fields="['code','hasCompany']"
fields="['code','hasCompany']"
url="/client/api/Samples"
show-field="description"
value-field="id"

View File

@ -0,0 +1,238 @@
import ngModule from '../../module';
import ModelProxy from '../model-proxy/model-proxy';
export default class ArrayModel extends ModelProxy {
constructor($q, $filter) {
super();
this.$q = $q;
this.$filter = $filter;
this.autoLoad = true;
this.limit = 0;
this.userFilter = [];
this.clear();
}
$onInit() {
this.autoRefresh();
}
/**
* Whether the model is loading.
*/
get isLoading() {
return false;
}
autoRefresh() {
if (this.autoLoad)
return this.refresh();
return this.$q.resolve();
}
/**
* Refresh the model data.
*
* @return {Promise} The request promise
*/
refresh() {
this.data = this.proccessData(0);
return this.$q.resolve();
}
proccessData(skip) {
if (!this.proxiedData) return null;
let data = this.proxiedData.slice();
let filter = {
order: this.order,
limit: this.limit
};
if (this.where)
filter.where = [this.where];
filter = this.mergeFilters(this.userFilter, filter);
let where = filter.where;
if (where) {
if (!Array.isArray(where))
where = [where];
for (let subWhere of where)
data = this.$filter('filter')(data, subWhere);
}
let order = filter.order;
if (typeof order === 'string')
order = order.split(/\s*,\s*/);
if (Array.isArray(order)) {
let orderComp = [];
for (let field of order) {
let split = field.split(/\s+/);
orderComp.push({
field: split[0],
way: split[1] === 'DESC' ? -1 : 1
});
}
data.sort((a, b) => this.sortFunc(a, b, orderComp));
} else if (typeof order === 'function')
data.sort(order);
this.skip = skip;
if (filter.limit) {
let end = skip + filter.limit;
this.moreRows = end < data.length;
data = data.slice(this.skip, end);
} else
this.moreRows = false;
this.currentFilter = filter;
return data;
}
applyFilter(userFilter, userParams) {
this.userFilter = userFilter;
this.userParams = userParams;
return this.refresh();
}
addFilter(userFilter, userParams) {
this.userFilter = this.mergeFilters(userFilter, this.userFilter);
Object.assign(this.userParams, userParams);
return this.refresh();
}
removeFilter() {
return applyFilter(null, null);
}
/**
* When limit is enabled, loads the next set of rows.
*
* @return {Promise} The request promise
*/
loadMore() {
if (!this.moreRows)
return this.$q.resolve();
let data = this.proccessData(this.skip + this.currentFilter.limit);
this.data = this.data.concat(data);
return this.$q.resolve();
}
/**
* Clears the model, removing all it's data.
*/
clear() {
this.data = null;
this.userFilter = null;
this.moreRows = false;
this.skip = 0;
}
/**
* Saves current changes on the server.
*
* @return {Promise} The save request promise
*/
save() {
if (this.getChanges())
this.orgData = this.data;
return this.$q.resolve();
}
sortFunc(a, b, order) {
for (let i of order) {
let compRes = this.compareFunc(a[i.field], b[i.field]) * i.way;
if (compRes !== 0)
return compRes;
}
return 0;
}
compareFunc(a, b) {
if (a === b)
return 0;
let aType = typeof a;
if (aType === typeof b) {
switch (aType) {
case 'string':
return a.localeCompare(b);
case 'number':
return a - b;
case 'boolean':
return a ? 1 : -1;
case 'object':
if (a instanceof Date && b instanceof Date)
return a.getTime() - b.getTime();
}
}
if (a === undefined)
return -1;
if (b === undefined)
return 1;
if (a === null)
return -1;
if (b === null)
return 1;
return a > b ? 1 : -1;
}
mergeFilters(src, dst) {
let mergedWhere = [];
let wheres = [dst.where, src.where];
for (let where of wheres) {
if (Array.isArray(where))
mergedWhere = mergedWhere.concat(where);
else if (where)
mergedWhere.push(where);
}
switch (mergedWhere.length) {
case 0:
mergedWhere = undefined;
break;
case 1:
mergedWhere = mergedWhere[0];
break;
}
return {
where: mergedWhere,
order: src.order || dst.order,
limit: src.limit || dst.limit
};
}
undoChanges() {
super.undoChanges();
this.refresh();
}
}
ArrayModel.$inject = ['$q'];
ngModule.component('vnArrayModel', {
controller: ArrayModel,
bindings: {
orgData: '<?',
data: '=?',
link: '<?',
order: '@?',
limit: '<?',
autoLoad: '<?'
}
});

View File

@ -20,5 +20,6 @@
</div>
<vn-drop-down
vn-id="drop-down"
on-select="$ctrl.onDropDownSelect(value)">
on-select="$ctrl.onDropDownSelect(value)"
on-data-ready="$ctrl.onDataReady()">
</vn-drop-down>

View File

@ -1,6 +1,6 @@
import ngModule from '../../module';
import Input from '../../lib/input';
import asignProps from '../../lib/asign-props';
import assignProps from '../../lib/assign-props';
import './style.scss';
/**
@ -11,6 +11,8 @@ import './style.scss';
* @property {Array} data Static data for the autocomplete
* @property {Object} intialData A initial data to avoid the server request used to get the selection
* @property {Boolean} multiple Wether to allow multiple selection
*
* @event change Thrown when value is changed
*/
export default class Autocomplete extends Input {
constructor($element, $scope, $http, $transclude) {
@ -20,9 +22,6 @@ export default class Autocomplete extends Input {
this._field = undefined;
this._selection = null;
this.valueField = 'id';
this.showField = 'name';
this._multiField = [];
this.readonly = true;
this.form = null;
this.input = this.element.querySelector('.mdl-textfield__input');
@ -31,15 +30,41 @@ export default class Autocomplete extends Input {
this.element.querySelector('.mdl-textfield'));
}
set url(value) {
this._url = value;
$postLink() {
this.assignDropdownProps();
this.showField = this.$.dropDown.showField;
this.valueField = this.$.dropDown.valueField;
this.linked = true;
this.refreshSelection();
}
get model() {
return this._model;
}
set model(value) {
this._model = value;
this.assignDropdownProps();
}
get data() {
return this._data;
}
set data(value) {
this._data = value;
this.assignDropdownProps();
}
get url() {
return this._url;
}
set url(value) {
this._url = value;
this.assignDropdownProps();
}
/**
* @type {any} The autocomplete value.
*/
@ -53,9 +78,7 @@ export default class Autocomplete extends Input {
this._field = value;
this.refreshSelection();
if (this.onChange)
this.onChange({value});
this.emit('change', {value});
}
/**
@ -71,15 +94,6 @@ export default class Autocomplete extends Input {
this.refreshDisplayed();
}
set data(value) {
this._data = value;
this.refreshSelection();
}
get data() {
return this._data;
}
selectionIsValid(selection) {
return selection
&& selection[this.valueField] == this._field
@ -92,16 +106,16 @@ export default class Autocomplete extends Input {
let value = this._field;
if (value && this.valueField && this.showField) {
if (value && this.linked) {
if (this.selectionIsValid(this.initialData)) {
this.selection = this.initialData;
return;
}
let data = this.data;
if (!data && this.$.dropDown)
data = this.$.dropDown.$.model.data;
if (this.$.dropDown) {
let data;
if (this.$.dropDown.model)
data = this.$.dropDown.model.orgData;
if (data)
for (let i = 0; i < data.length; i++)
@ -110,15 +124,14 @@ export default class Autocomplete extends Input {
return;
}
if (this.url) {
this.requestSelection(value);
return;
}
} else
this.selection = null;
}
requestSelection(value) {
if (!this.url) return;
let where = {};
if (this.multiple)
@ -127,7 +140,7 @@ export default class Autocomplete extends Input {
where[this.valueField] = value;
let filter = {
fields: this.getFields(),
fields: this.$.dropDown.getFields(),
where: where
};
@ -170,18 +183,6 @@ export default class Autocomplete extends Input {
this.mdlUpdate();
}
getFields() {
let fields = [];
fields.push(this.valueField);
fields.push(this.showField);
if (this.selectFields)
for (let field of this.selectFields)
fields.push(field);
return fields;
}
mdlUpdate() {
let field = this.element.querySelector('.mdl-textfield');
let mdlField = field.MaterialTextfield;
@ -227,26 +228,34 @@ export default class Autocomplete extends Input {
this.showDropDown();
}
showDropDown(search) {
Object.assign(this.$.dropDown.$.model, {
url: this.url,
staticData: this._data
});
onDataReady() {
this.refreshSelection();
}
asignProps(this, this.$.dropDown, [
assignDropdownProps() {
if (!this.$.dropDown) return;
assignProps(this, this.$.dropDown, [
'valueField',
'showField',
'showFilter',
'multiple',
'$transclude',
'translateFields',
'model',
'data',
'url',
'fields',
'include',
'where',
'order',
'limit',
'showFilter',
'multiple',
'$transclude'
'searchFunction'
]);
}
this.$.dropDown.selectFields = this.getFields();
this.$.dropDown.parent = this.input;
this.$.dropDown.show(search);
showDropDown(search) {
this.assignDropdownProps();
this.$.dropDown.show(this.input, search);
}
}
Autocomplete.$inject = ['$element', '$scope', '$http', '$transclude'];
@ -255,22 +264,23 @@ ngModule.component('vnAutocomplete', {
template: require('./autocomplete.html'),
controller: Autocomplete,
bindings: {
url: '@?',
data: '<?',
label: '@',
field: '=?',
disabled: '<?',
showField: '@?',
valueField: '@?',
selectFields: '<?',
disabled: '<?',
where: '@?',
order: '@?',
label: '@',
initialData: '<?',
field: '=?',
limit: '<?',
showFilter: '<?',
selection: '<?',
multiple: '<?',
onChange: '&?'
data: '<?',
url: '@?',
fields: '<?',
include: '<?',
where: '<?',
order: '@?',
limit: '<?',
searchFunction: '&?'
},
transclude: {
tplItem: '?tplItem'

View File

@ -1,8 +1,9 @@
import ngModule from '../../module';
import ModelProxy from '../model-proxy/model-proxy';
import {mergeWhere, mergeFilters} from 'vn-loopback/common/filter.js';
export default class CrudModel extends ModelProxy {
constructor($http, $q) {
constructor($q, $http) {
super();
this.$http = $http;
this.$q = $q;
@ -10,6 +11,10 @@ export default class CrudModel extends ModelProxy {
this.autoLoad = true;
}
$onInit() {
this.autoRefresh();
}
/**
* Whether the model is loading.
*/
@ -17,9 +22,21 @@ export default class CrudModel extends ModelProxy {
return this.canceler != null;
}
$onInit() {
set url(url) {
if (this._url === url) return;
this._url = url;
this.clear();
this.autoRefresh();
}
get url() {
return this._url;
}
autoRefresh() {
if (this.autoLoad)
this.refresh();
return this.refresh();
return this.$q.resolve();
}
buildFilter() {
@ -48,7 +65,8 @@ export default class CrudModel extends ModelProxy {
* @return {Promise} The request promise
*/
refresh() {
if (!this.url) return;
if (!this._url)
return this.$q.resolve();
return this.sendRequest(this.buildFilter());
}
@ -84,9 +102,7 @@ export default class CrudModel extends ModelProxy {
* @return {Promise} The request promise
*/
removeFilter() {
this.userFilter = null;
this.userParams = null;
return this.refresh();
return applyFilter(null, null);
}
/**
@ -101,12 +117,23 @@ export default class CrudModel extends ModelProxy {
/**
* When limit is enabled, loads the next set of rows.
*
* @return {Promise} The request promise
*/
loadMore() {
if (!this.moreRows) return;
if (!this.moreRows)
return this.$q.resolve();
let filter = Object.assign({}, this.currentFilter);
filter.skip = this.orgData ? this.orgData.length : 0;
this.sendRequest(filter, true);
return this.sendRequest(filter, true);
}
/**
* Clears the model, removing all it's data.
*/
clear() {
this.orgData = null;
}
/**
@ -115,27 +142,30 @@ export default class CrudModel extends ModelProxy {
* @return {Object} The current changes
*/
getChanges() {
if (!this.isChanged)
return null;
let create = [];
let update = [];
let remove = [];
for (let row of this.removed)
remove.push(row.$data[this.primaryKey]);
remove.push(row[this.primaryKey]);
for (let row of this._data) {
if (row.$isNew)
create.push(row.$data);
else if (row.$oldData)
update.push(row.$data);
if (row.$isNew) {
let data = {};
for (let prop in row)
if (prop.charAt(0) !== '$')
data[prop] = row[prop];
create.push(data);
} else if (row.$oldData) {
let data = {};
for (let prop in row.$oldData)
data[prop] = row[prop];
update.push(data);
}
}
let isChanged =
create.length > 0 ||
update.length > 0 ||
remove.length > 0;
if (!isChanged)
return null;
let changes = {
create: create,
@ -157,9 +187,9 @@ export default class CrudModel extends ModelProxy {
if (!changes)
return this.$q.resolve();
let url = this.saveUrl ? this.saveUrl : `${this.url}/crud`;
let url = this.saveUrl ? this.saveUrl : `${this._url}/crud`;
return this.$http.post(url, changes)
.then(() => this.resetChanges());
.then(() => this.applyChanges());
}
buildParams() {
@ -186,7 +216,7 @@ export default class CrudModel extends ModelProxy {
params: params
};
return this.$http.get(this.url, options).then(
return this.$http.get(this._url, options).then(
json => this.onRemoteDone(json, filter, append),
json => this.onRemoteError(json)
);
@ -202,9 +232,9 @@ export default class CrudModel extends ModelProxy {
this.currentFilter = filter;
}
this.data = this.proxiedData.slice();
this.moreRows = filter.limit && data.length == filter.limit;
this.onRequestEnd();
this.dataChanged();
}
onRemoteError(err) {
@ -215,8 +245,13 @@ export default class CrudModel extends ModelProxy {
onRequestEnd() {
this.canceler = null;
}
undoChanges() {
super.undoChanges();
this.data = this.proxiedData.slice();
}
CrudModel.$inject = ['$http', '$q'];
}
CrudModel.$inject = ['$q', '$http'];
ngModule.component('vnCrudModel', {
controller: CrudModel,
@ -224,12 +259,12 @@ ngModule.component('vnCrudModel', {
orgData: '<?',
data: '=?',
onDataChange: '&?',
fields: '<?',
link: '<?',
url: '@?',
saveUrl: '@?',
where: '<?',
fields: '<?',
include: '<?',
where: '<?',
order: '@?',
limit: '<?',
filter: '<?',
@ -240,80 +275,3 @@ ngModule.component('vnCrudModel', {
autoLoad: '<?'
}
});
/**
* Passes a loopback fields filter to an object.
*
* @param {Object} fields The fields object or array
* @return {Object} The fields as object
*/
function fieldsToObject(fields) {
let fieldsObj = {};
if (Array.isArray(fields))
for (let field of fields)
fieldsObj[field] = true;
else if (typeof fields == 'object')
for (let field in fields)
if (fields[field])
fieldsObj[field] = true;
return fieldsObj;
}
/**
* Merges two loopback fields filters.
*
* @param {Object|Array} src The source fields
* @param {Object|Array} dst The destination fields
* @return {Array} The merged fields as an array
*/
function mergeFields(src, dst) {
let fields = {};
Object.assign(fields,
fieldsToObject(src),
fieldsToObject(dst)
);
return Object.keys(fields);
}
/**
* Merges two loopback where filters.
*
* @param {Object|Array} src The source where
* @param {Object|Array} dst The destination where
* @return {Array} The merged wheres
*/
function mergeWhere(src, dst) {
let and = [];
if (src) and.push(src);
if (dst) and.push(dst);
return and.length > 1 ? {and} : and[0];
}
/**
* Merges two loopback filters returning the merged filter.
*
* @param {Object} src The source filter
* @param {Object} dst The destination filter
* @return {Object} The result filter
*/
function mergeFilters(src, dst) {
let res = Object.assign({}, dst);
if (!src)
return res;
if (src.fields)
res.fields = mergeFields(src.fields, res.fields);
if (src.where)
res.where = mergeWhere(res.where, src.where);
if (src.include)
res.include = src.include;
if (src.order)
res.order = src.order;
if (src.limit)
res.limit = src.limit;
return res;
}

View File

@ -1,7 +1,3 @@
<vn-rest-model
vn-id="model"
on-data-change="$ctrl.onModelDataChange()">
</vn-rest-model>
<vn-popover
vn-id="popover"
on-open="$ctrl.onOpen()"

View File

@ -1,13 +1,23 @@
import './style.scss';
import ngModule from '../../module';
import Component from '../../lib/component';
import './style.scss';
import ArrayModel from '../array-model/array-model';
import CrudModel from '../crud-model/crud-model';
import {mergeWhere} from 'vn-loopback/common/filter.js';
/**
* @event select Thrown when model item is selected
* @event change Thrown when model data is ready
*/
export default class DropDown extends Component {
constructor($element, $scope, $transclude, $timeout, $translate) {
constructor($element, $scope, $transclude, $timeout, $translate, $http, $q, $filter) {
super($element, $scope);
this.$transclude = $transclude;
this.$timeout = $timeout;
this.$translate = $translate;
this.$http = $http;
this.$q = $q;
this.$filter = $filter;
this.valueField = 'id';
this.showField = 'name';
@ -27,7 +37,7 @@ export default class DropDown extends Component {
}
get shown() {
return this.$.popover.shown;
return this.$.popover && this.$.popover.shown;
}
set shown(value) {
@ -39,43 +49,47 @@ export default class DropDown extends Component {
}
set search(value) {
value = value == '' || value == null ? null : value;
if (value === this._search && this.$.model.data != null) return;
let oldValue = this._search;
this._search = value;
this.$.model.clear();
if (!this.shown) return;
value = value == '' || value == null ? null : value;
oldValue = oldValue == '' || oldValue == null ? null : oldValue;
if (value === oldValue && this.modelData != null) return;
if (value != null)
this._activeOption = 0;
this.$timeout.cancel(this.searchTimeout);
if (this.model) {
this.model.clear();
this.searchTimeout = this.$timeout(() => {
this.refreshModel();
this.searchTimeout = null;
}, 350);
}
this.buildList();
}
get statusText() {
let model = this.$.model;
let data = model.data;
let statusText = null;
let model = this.model;
let data = this.modelData;
if (!model)
return 'No data';
if (model.isLoading || this.searchTimeout)
statusText = 'Loading...';
else if (data == null)
statusText = 'No data';
else if (model.moreRows)
statusText = 'Load More';
else if (data.length === 0)
statusText = 'No results found';
return 'Loading...';
if (data == null)
return 'No data';
if (model.moreRows)
return 'Load More';
if (data.length === 0)
return 'No results found';
return statusText;
}
get model() {
return this.$.model;
return null;
}
get activeOption() {
@ -86,14 +100,14 @@ export default class DropDown extends Component {
* Shows the drop-down. If a parent is specified it is shown in a visible
* relative position to it.
*
* @param {HTMLElement} parent The parent element to show drop down relative to
* @param {String} search The initial search term or %null
*/
show(search) {
show(parent, search) {
this._activeOption = -1;
this.search = search;
this.buildList();
this.$.popover.parent = this.parent;
this.$.popover.show();
this.$.popover.show(parent || this.parent);
}
/**
@ -135,7 +149,7 @@ export default class DropDown extends Component {
if (this.activeLi)
this.activeLi.className = '';
let data = this.$.model.data;
let data = this.modelData;
if (option >= 0 && data && option < data.length) {
this.activeLi = this.ul.children[option];
@ -149,7 +163,7 @@ export default class DropDown extends Component {
* @param {Number} option The option index
*/
selectOption(option) {
let data = this.$.model.data;
let data = this.modelData;
let item = option != -1 && data ? data[option] : null;
if (item) {
@ -168,47 +182,13 @@ export default class DropDown extends Component {
this.field = value;
}
if (this.onSelect)
this.onSelect({value: value});
this.emit('select', {value: value});
}
if (!this.multiple)
this.$.popover.hide();
}
refreshModel() {
this.$.model.filter = {
fields: this.selectFields,
where: this.getWhere(this._search),
order: this.getOrder(),
limit: this.limit || 8
};
this.$.model.refresh(this._search);
}
getWhere(search) {
if (search == '' || search == null)
return undefined;
if (this.where) {
let jsonFilter = this.where.replace(/search/g, search);
return this.$.$eval(jsonFilter);
}
let where = {};
where[this.showField] = {like: `%${search}%`};
return where;
}
getOrder() {
if (this.order)
return this.order;
else if (this.showField)
return `${this.showField} ASC`;
return undefined;
}
onOpen() {
this.document.addEventListener('keydown', this.docKeyDownHandler);
this.list.scrollTop = 0;
@ -227,15 +207,15 @@ export default class DropDown extends Component {
let list = this.list;
let shouldLoad =
list.scrollTop + list.clientHeight >= (list.scrollHeight - 40)
&& !this.$.model.isLoading;
&& !this.model.isLoading;
if (shouldLoad)
this.$.model.loadMore();
this.model.loadMore();
}
onLoadMoreClick(event) {
event.preventDefault();
this.$.model.loadMore();
this.model.loadMore();
}
onContainerClick(event) {
@ -244,14 +224,10 @@ export default class DropDown extends Component {
if (index != -1) this.selectOption(index);
}
onModelDataChange() {
this.buildList();
}
onDocKeyDown(event) {
if (event.defaultPrevented) return;
let data = this.$.model.data;
let data = this.modelData;
let option = this.activeOption;
let nOpts = data ? data.length - 1 : 0;
@ -283,11 +259,12 @@ export default class DropDown extends Component {
}
buildList() {
if (!this.shown) return;
this.destroyList();
let hasTemplate = this.$transclude && this.$transclude.isSlotFilled('tplItem');
let fragment = this.document.createDocumentFragment();
let data = this.$.model.data;
let data = this.modelData;
if (data) {
for (let i = 0; i < data.length; i++) {
@ -339,11 +316,109 @@ export default class DropDown extends Component {
this.scopes = [];
}
getFields() {
let fields = [];
fields.push(this.valueField);
fields.push(this.showField);
if (this.fields)
for (let field of this.fields)
fields.push(field);
return fields;
}
$onDestroy() {
this.destroyList();
}
// Model related code
onDataChange() {
if (this.model.orgData)
this.emit('dataReady');
this.buildList();
}
DropDown.$inject = ['$element', '$scope', '$transclude', '$timeout', '$translate'];
get modelData() {
return this._model ? this._model.data : null;
}
get model() {
return this._model;
}
set model(value) {
this.linkEvents({_model: value}, {dataChange: this.onDataChange});
this.onDataChange();
}
get url() {
return this._url;
}
set url(value) {
this._url = value;
if (value) {
this.model = new CrudModel(this.$q, this.$http);
this.model.autoLoad = false;
this.model.url = value;
}
}
get data() {
return this._data;
}
set data(value) {
this._data = value;
if (value) {
this.model = new ArrayModel(this.$q, this.$filter);
this.model.autoLoad = false;
this.model.orgData = value;
}
}
refreshModel() {
let model = this.model;
let order;
if (this.order)
order = this.order;
else if (this.showField)
order = `${this.showField} ASC`;
let filter = {
order,
limit: this.limit || 8
};
if (model instanceof CrudModel) {
let searchExpr = this._search == null
? null
: this.searchFunction({$search: this._search});
Object.assign(filter, {
fields: this.getFields(),
include: this.include,
where: mergeWhere(this.where, searchExpr)
});
} else if (model instanceof ArrayModel) {
if (this._search != null)
filter.where = this.searchFunction({$search: this._search});
}
return this.model.applyFilter(filter);
}
searchFunction(scope) {
if (this.model instanceof CrudModel)
return {[this.showField]: {like: `%${scope.$search}%`}};
if (this.model instanceof ArrayModel)
return {[this.showField]: scope.$search};
}
}
DropDown.$inject = ['$element', '$scope', '$transclude', '$timeout', '$translate', '$http', '$q', '$filter'];
/**
* Gets the position of an event element relative to a parent.
@ -374,15 +449,21 @@ ngModule.component('vnDropDown', {
controller: DropDown,
bindings: {
field: '=?',
data: '<?',
selection: '=?',
search: '<?',
limit: '<?',
showFilter: '<?',
parent: '<?',
multiple: '<?',
onSelect: '&?',
translateFields: '<?'
translateFields: '<?',
data: '<?',
url: '@?',
fields: '<?',
include: '<?',
where: '<?',
order: '@?',
limit: '<?',
searchFunction: '&?'
},
transclude: {
tplItem: '?tplItem'

View File

@ -1,6 +1,6 @@
import ngModule from '../../module';
import Input from '../../lib/input';
import asignProps from '../../lib/asign-props';
import assignProps from '../../lib/assign-props';
import './style.scss';
export default class IconMenu extends Input {
@ -8,20 +8,6 @@ export default class IconMenu extends Input {
super($element, $scope);
this.$transclude = $transclude;
this.input = this.element.querySelector('.mdl-button');
this.valueField = 'id';
this.showField = 'name';
}
getFields() {
let fields = [];
fields.push(this.valueField);
fields.push(this.showField);
if (this.selectFields)
for (let field of this.selectFields)
fields.push(field);
return fields;
}
onClick(event) {
@ -38,26 +24,24 @@ export default class IconMenu extends Input {
}
showDropDown() {
Object.assign(this.$.dropDown.$.model, {
url: this.url,
staticData: this.data
});
asignProps(this, this.$.dropDown, [
assignProps(this, this.$.dropDown, [
'valueField',
'showField',
'where',
'order',
'showFilter',
'multiple',
'limit',
'$transclude',
'translateFields'
'translateFields',
'model',
'data',
'url',
'fields',
'include',
'where',
'order',
'limit',
'searchFunction'
]);
this.$.dropDown.selectFields = this.getFields();
this.$.dropDown.parent = this.input;
this.$.dropDown.show();
this.$.dropDown.show(this.input);
}
}
IconMenu.$inject = ['$element', '$scope', '$transclude'];
@ -65,20 +49,20 @@ IconMenu.$inject = ['$element', '$scope', '$transclude'];
ngModule.component('vnIconMenu', {
template: require('./icon-menu.html'),
bindings: {
url: '@?',
data: '<?',
label: '@',
showField: '@?',
selection: '<?',
valueField: '@?',
selectFields: '<?',
disabled: '<?',
initialData: '<?',
showFilter: '<?',
field: '=?',
url: '@?',
data: '<?',
where: '@?',
order: '@?',
label: '@',
initialData: '<?',
field: '=?',
limit: '<?',
showFilter: '<?',
selection: '<?',
multiple: '<?',
onChange: '&?',
icon: '@?',

View File

@ -1,6 +1,5 @@
import './model-proxy/model-proxy';
import './rest-model/crud-model';
import './rest-model/rest-model';
import './crud-model/crud-model';
import './watcher/watcher';
import './textfield/textfield';
import './icon/icon';

View File

@ -1,8 +1,66 @@
import ngModule from '../../module';
import EventEmitter from '../../lib/event-emitter';
export default class ModelProxy {
/**
* A filtrable model.
*
* @property {Function} filter The filter function
*/
export class Filtrable {
applyFilter(userFilter, userParams) {}
addFilter(userFilter, userParams) {}
removeFilter() {}
}
/**
* A sortable model.
*
* @property {String|Array<String>|Function} order The sort specification
*/
export class Sortable {
}
/**
* Paginable model.
*
* @property {Number} limit The page size
*/
export class Paginable {
get isLoading() {}
get moreRows() {}
loadMore() {}
}
/**
* A data model.
*
* @event dataChange Emitted when data property changes
*/
export class DataModel extends EventEmitter {
get data() {}
refresh() {}
clear() {}
}
ngModule.component('vnDataModel', {
controller: DataModel,
bindings: {
data: '=?',
autoLoad: '<?',
autoSync: '<?',
order: '@?',
limit: '<?'
}
});
/**
* A data model that monitorizes changes to the data.
*
* @event dataChange Emitted when data property changes
*/
export default class ModelProxy extends DataModel {
constructor() {
this._data = [];
super();
this.resetChanges();
}
@ -12,18 +70,16 @@ export default class ModelProxy {
set orgData(value) {
this._orgData = value;
// this._data.splice(0, this._data.length);
if (this.Row) {
this._data = [];
if (value) {
this.proxiedData = new Array(value.length);
for (let i = 0; i < value.length; i++) {
let row = new this.Row(value[i], i);
this._data.push(row);
}
for (let i = 0; i < value.length; i++)
this.proxiedData[i] = this.createRow(Object.assign({}, value[i]), i);
} else
this._data = value;
this.proxiedData = null;
this.data = null;
this.resetChanges();
}
@ -31,95 +87,122 @@ export default class ModelProxy {
return this._data;
}
set data(value) {}
get fields() {
return this._fields;
}
set fields(value) {
this._fields = value;
let Row = function(data, index) {
this.$data = data;
this.$index = index;
this.$oldData = null;
this.$isNew = false;
};
for (let prop of value) {
Object.defineProperty(Row.prototype, prop, {
enumerable: true,
configurable: false,
set: function(value) {
if (!this.$isNew) {
if (!this.$oldData)
this.$oldData = {};
if (!this.$oldData[prop])
this.$oldData[prop] = this.$data[prop];
}
this.$data[prop] = value;
},
get: function() {
return this.$data[prop];
}
});
}
this.Row = Row;
set data(value) {
this._data = value;
this.emit('dataChange');
}
remove(index) {
let data = this._data;
let [item] = this.data.splice(index, 1);
let item;
[item] = data.splice(index, 1);
for (let i = index; i < data.length; i++)
data[i].$index = i;
let proxiedIndex = this.proxiedData.indexOf(item);
this.proxiedData.splice(proxiedIndex, 1);
if (!item.$isNew)
this.removed.push(item);
this.isChanged = true;
this.emit('rowRemove', index);
}
insert(data) {
data = Object.assign(data || {}, this.link);
let newRow = new this.Row(data, this._data.length);
let newRow = this.createRow(data, null);
newRow.$isNew = true;
return this._data.push(newRow);
let index = this.proxiedData.push(newRow) - 1;
if (this.data)
this.data.push(newRow);
this.isChanged = true;
this.emit('rowInsert', index);
return index;
}
createRow(obj, index) {
let proxy = new Proxy(obj, {
set: (obj, prop, value) => {
if (prop.charAt(0) !== '$' && value !== obj[prop] && !obj.$isNew) {
if (!obj.$oldData)
obj.$oldData = {};
if (!obj.$oldData[prop])
obj.$oldData[prop] = value;
this.isChanged = true;
}
return Reflect.set(obj, prop, value);
}
});
Object.assign(proxy, {
$orgIndex: index,
$oldData: null,
$isNew: false
});
return proxy;
}
resetChanges() {
this.removed = [];
this.isChanged = false;
for (let row of this._data) {
let data = this.proxiedData;
if (data)
for (let row of data)
row.$oldData = null;
row.$isNew = false;
}
applyChanges() {
let data = this.proxiedData;
let orgData = this.orgData;
if (!data) return;
for (let row of this.removed) {
if (row.$isNew) {
let data = {};
for (let prop in row)
if (prop.charAt(0) !== '$')
data[prop] = row[prop];
row.$orgIndex = orgData.push(data) - 1;
row.$isNew = false;
} else if (row.$oldData)
for (let prop in row.$oldData)
orgData[row.$orgIndex][prop] = row[prop];
}
let removed = this.removed;
if (removed) {
removed = removed.sort((a, b) => b.$orgIndex - a.$orgIndex);
for (let row of this.removed)
orgData.splice(row.$orgIndex, 1);
}
this.resetChanges();
}
undoChanges() {
let data = this._data;
let data = this.proxiedData;
if (!data) return;
for (let i = 0; i < data.length; i++) {
let row = data[i];
if (row.$oldData)
Object.assign(row.$data, row.$oldData);
Object.assign(row, row.$oldData);
if (row.$isNew)
data.splice(i--, 1);
}
for (let row of this.removed)
data.splice(row.$index, 0, row);
let removed = this.removed;
this.resetChanges();
if (removed) {
removed = removed.sort((a, b) => a.$orgIndex - b.$orgIndex);
for (let row of this.removed)
data.splice(row.$orgIndex, 0, row);
}
dataChanged() {
if (this.onDataChange)
this.onDataChange();
this.resetChanges();
}
}
@ -127,9 +210,6 @@ ngModule.component('vnModelProxy', {
controller: ModelProxy,
bindings: {
orgData: '<?',
data: '=?',
fields: '<?',
link: '<?',
onDataChange: '&?'
data: '=?'
}
});

View File

@ -4,6 +4,11 @@ import './style.scss';
/**
* A simple popover.
*
* @property {HTMLElement} parent The parent element to show drop down relative to
*
* @event open Thrown when popover is displayed
* @event close Thrown when popover is hidden
*/
export default class Popover extends Component {
constructor($element, $scope, $timeout, $transitions) {
@ -61,10 +66,13 @@ export default class Popover extends Component {
/**
* Shows the popover. If a parent is specified it is shown in a visible
* relative position to it.
*
* @param {HTMLElement} parent Overrides the parent property
*/
show() {
show(parent) {
if (this._shown) return;
if (parent) this.parent = parent;
this._shown = true;
this.element.style.display = 'block';
this.$timeout.cancel(this.showTimeout);
@ -78,9 +86,7 @@ export default class Popover extends Component {
this.deregisterCallback = this.$transitions.onStart({}, () => this.hide());
this.relocate();
if (this.onOpen)
this.onOpen();
this.emit('open');
}
/**
@ -95,9 +101,7 @@ export default class Popover extends Component {
this.showTimeout = this.$timeout(() => {
this.element.style.display = 'none';
this.showTimeout = null;
if (this.onClose)
this.onClose();
this.emit('close');
}, 250);
this.document.removeEventListener('keydown', this.docKeyDownHandler);
@ -187,9 +191,5 @@ Popover.$inject = ['$element', '$scope', '$timeout', '$transitions'];
ngModule.component('vnPopover', {
template: require('./popover.html'),
controller: Popover,
transclude: true,
bindings: {
onOpen: '&?',
onClose: '&?'
}
transclude: true
});

View File

@ -1,127 +0,0 @@
import ngModule from '../../module';
export default class RestModel {
constructor($http, $q, $filter) {
this.$http = $http;
this.$q = $q;
this.$filter = $filter;
this.filter = null;
this.clear();
}
set staticData(value) {
this._staticData = value;
this.refresh();
}
get staticData() {
return this._staticData;
}
get isLoading() {
return this.canceler != null;
}
set url(url) {
if (this._url != url) {
this._url = url;
this.clear();
}
}
get url() {
return this._url;
}
loadMore() {
if (this.moreRows) {
let filter = Object.assign({}, this.myFilter);
filter.skip += filter.limit;
this.sendRequest(filter, true);
}
}
clear() {
this.cancelRequest();
this.data = null;
this.moreRows = false;
this.dataChanged();
}
refresh(search) {
if (this.url) {
let filter = Object.assign({}, this.filter);
if (filter.limit)
filter.skip = 0;
this.clear();
this.sendRequest(filter);
} else if (this._staticData) {
if (search)
this.data = this.$filter('filter')(this._staticData, search);
else
this.data = this._staticData;
this.dataChanged();
}
}
cancelRequest() {
if (this.canceler) {
this.canceler.resolve();
this.canceler = null;
this.request = null;
}
}
sendRequest(filter, append) {
this.cancelRequest();
this.canceler = this.$q.defer();
let options = {timeout: this.canceler.promise};
let json = encodeURIComponent(JSON.stringify(filter));
this.request = this.$http.get(`${this.url}?filter=${json}`, options).then(
json => this.onRemoteDone(json, filter, append),
json => this.onRemoteError(json)
);
}
onRemoteDone(json, filter, append) {
let data = json.data;
if (append)
this.data = this.data.concat(data);
else
this.data = data;
this.myFilter = filter;
this.moreRows = filter.limit && data.length == filter.limit;
this.onRequestEnd();
this.dataChanged();
}
onRemoteError(json) {
this.onRequestEnd();
}
onRequestEnd() {
this.request = null;
this.canceler = null;
}
dataChanged() {
if (this.onDataChange)
this.onDataChange();
}
}
RestModel.$inject = ['$http', '$q', '$filter'];
ngModule.component('vnRestModel', {
controller: RestModel,
bindings: {
url: '@?',
staticData: '<?',
data: '=?',
limit: '<?',
onDataChange: '&?'
}
});

View File

@ -1,34 +0,0 @@
import './rest-model.js';
describe('Component vnRestModel', () => {
let $componentController;
let $httpBackend;
let controller;
beforeEach(() => {
angular.mock.module('client');
});
beforeEach(angular.mock.inject((_$componentController_, _$httpBackend_) => {
$componentController = _$componentController_;
controller = $componentController('vnRestModel', {$httpBackend});
}));
describe('set url', () => {
it(`should call clear function when the controller _url is undefined`, () => {
spyOn(controller, 'clear');
controller.url = 'localhost';
expect(controller._url).toEqual('localhost');
expect(controller.clear).toHaveBeenCalledWith();
});
it(`should do nothing when the url is matching`, () => {
controller._url = 'localhost';
spyOn(controller, 'clear');
controller.url = 'localhost';
expect(controller.clear).not.toHaveBeenCalledWith();
});
});
});

View File

@ -1,7 +1,22 @@
import EventEmitter from './event-emitter';
/**
* Base class for component controllers.
*/
export default class Component {
export default class Component extends EventEmitter {
/**
* Contructor.
*
* @param {HTMLElement} $element The main component element
* @param {$rootScope.Scope} $scope The element scope
*/
constructor($element, $scope) {
super($element, $scope);
this.element = $element[0];
this.element.$ctrl = this;
this.$element = $element;
this.$ = $scope;
}
/**
* The component owner window.
*/
@ -14,17 +29,5 @@ export default class Component {
get document() {
return this.element.ownerDocument;
}
/**
* Contructor.
*
* @param {HTMLElement} $element The main component element
* @param {$rootScope.Scope} $scope The element scope
*/
constructor($element, $scope) {
this.element = $element[0];
this.element.$ctrl = this;
this.$element = $element;
this.$ = $scope;
}
}
Component.$inject = ['$element', '$scope'];

View File

@ -0,0 +1,91 @@
import {kebabToCamel} from './string';
export default class EventEmitter {
constructor($element, $scope) {
if (!$element) return;
let attrs = $element[0].attributes;
for (let attr of attrs) {
if (attr.name.substr(0, 2) !== 'on') continue;
let eventName = kebabToCamel(attr.name.substr(3));
let callback = locals => $scope.$parent.$eval(attr.nodeValue, locals);
this.on(eventName, callback);
}
}
/**
* Connects to an object event.
*
* @param {String} eventName The event name
* @param {Function} callback The callback function
* @param {Object} thisArg The scope for the callback or %null
*/
on(eventName, callback, thisArg) {
if (!this.$events)
this.$events = {};
if (!this.$events[eventName])
this.$events[eventName] = [];
this.$events[eventName].push({callback, thisArg});
}
/**
* Disconnects all handlers for callback.
*
* @param {Function} callback The callback function
*/
off(callback) {
if (!this.$events) return;
for (let event in this.$events)
for (let i = 0; i < event.length; i++)
if (event[i].callback === callback)
event.splice(i--, 1);
}
/**
* Disconnects all instance callbacks.
*
* @param {Object} thisArg The callbacks instance
*/
disconnect(thisArg) {
if (!this.$events) return;
for (let event in this.$events)
for (let i = 0; i < event.length; i++)
if (event[i].thisArg === thisArg)
event.splice(i--, 1);
}
/**
* Emits an event.
*
* @param {String} eventName The event name
* @param {...*} args Arguments to pass to the callbacks
*/
emit(eventName) {
if (!this.$events || !this.$events[eventName])
return;
let args = Array.prototype.slice.call(arguments, 1);
let callbacks = this.$events[eventName];
for (let callback of callbacks)
callback.callback.apply(callback.thisArg, args);
}
/**
* Links the object with another object events.
*
* @param {Object} propValue The property and the new value
* @param {Object} handlers The event handlers
*/
linkEvents(propValue, handlers) {
for (let prop in propValue) {
let value = propValue[prop];
if (this[prop])
this[prop].disconnect(this);
this[prop] = value;
if (value)
for (let event in handlers)
value.on(event, handlers[event], this);
}
}
}
EventEmitter.$inject = ['$element', '$scope'];

View File

@ -16,10 +16,10 @@
<vn-autocomplete vn-one
url="/item/api/ItemTypes"
label="Type"
select-fields=["code","name"]
fields="['code', 'name']"
value-field="id"
field="$ctrl.item.typeFk"
where="{or: [{code: {regexp: 'search'}}, {name: {regexp: 'search'}}]}">
search-function="{or: [{code: {regexp: $search}}, {name: {regexp: $search}}]}">
<tpl-item style="display: flex;">
<div style="width: 3em; padding-right: 1em;">{{::code}}</div>
<div>{{::name}}</div>
@ -31,7 +31,7 @@
show-field="description"
value-field="id"
field="$ctrl.item.intrastatFk"
where="{or: [{id: {regexp: 'search'}}, {description: {regexp: 'search'}}]}">
search-function="{or: [{id: {regexp: $search}}, {description: {regexp: $search}}]}">
<tpl-item style="display: flex;">
<div style="width: 6em; text-align: right; padding-right: 1em;">{{::id}}</div>
<div>{{::description}}</div>

View File

@ -30,7 +30,7 @@
show-field="description"
value-field="id"
field="$ctrl.item.intrastatFk"
where="{or: [{id: {regexp: 'search'}}, {description: {regexp: 'search'}}]}"
search-function="{or: [{id: {regexp: $search}}, {description: {regexp: $search}}]}"
initial-data="$ctrl.item.intrastat">
<tpl-item style="display: flex;">
<div style="width: 6em; text-align: right; padding-right: 1em;">{{::id}}</div>

View File

@ -3,7 +3,7 @@
url="/item/api/ItemTags"
fields="['id', 'itemFk', 'tagFk', 'value', 'priority']"
link="{itemFk: $ctrl.$stateParams.id}"
filter="{include: {relation: 'tag'}}"
include="$ctrl.include"
order="priority ASC"
data="itemTags">
</vn-crud-model>
@ -17,17 +17,16 @@
<vn-title>Tags</vn-title>
<vn-horizontal ng-repeat="itemTag in itemTags">
<vn-autocomplete
vn-id="tag"
vn-one
field="itemTag.tagFk"
url="/item/api/Tags"
select-fields="['id','name','isFree']"
show-field="name"
vn-id="tag"
label="Tag"
on-change="itemTag.value = null"
initial-data="itemTag.tag"
field="itemTag.tagFk"
show-field="name"
url="/item/api/Tags"
fields="$ctrl.include.scope.fields"
vn-acl="buyer"
vn-focus
disabled="itemTag.id != null">
vn-focus>
</vn-autocomplete>
<vn-textfield
ng-show="tag.selection.isFree || tag.selection.isFree == undefined"

View File

@ -4,6 +4,12 @@ class Controller {
constructor($stateParams, $scope) {
this.$stateParams = $stateParams;
this.$scope = $scope;
this.include = {
relation: 'tag',
scope: {
fields: ['id', 'name', 'isFree']
}
};
}
add() {

View File

@ -12,7 +12,7 @@
<vn-autocomplete
disabled="!$ctrl.clientFk"
url="{{ $ctrl.clientFk ? '/api/Clients/'+ $ctrl.clientFk +'/addresses' : null }}"
select-fields=["nickname","street","city"]
fields="['nickname', 'street', 'city']"
field="$ctrl.addressFk"
show-field="nickname"
value-field="id"

View File

@ -21,7 +21,7 @@
label="Package"
show-field="name"
value-field="packagingFk"
where="{or: [{'itemFk': {like: '%search%'}}, {'name': {like: '%search%'}}]}"
search-function="{or: [{itemFk: {like: '%'+ $search +'%'}}, {'name': {like: '%'+ $search +'%'}}]}"
field="package.packagingFk">
<tpl-item>{{itemFk}} : {{name}}</tpl-item>
</vn-autocomplete>

View File

@ -0,0 +1,92 @@
/**
* Passes a loopback fields filter to an object.
*
* @param {Object} fields The fields object or array
* @return {Object} The fields as object
*/
function fieldsToObject(fields) {
let fieldsObj = {};
if (Array.isArray(fields))
for (let field of fields)
fieldsObj[field] = true;
else if (typeof fields == 'object')
for (let field in fields)
if (fields[field])
fieldsObj[field] = true;
return fieldsObj;
}
/**
* Merges two loopback fields filters.
*
* @param {Object|Array} src The source fields
* @param {Object|Array} dst The destination fields
* @return {Array} The merged fields as an array
*/
function mergeFields(src, dst) {
let fields = {};
Object.assign(fields,
fieldsToObject(src),
fieldsToObject(dst)
);
return Object.keys(fields);
}
/**
* Merges two loopback where filters.
*
* @param {Object|Array} src The source where
* @param {Object|Array} dst The destination where
* @return {Array} The merged wheres
*/
function mergeWhere(src, dst) {
let and = [];
if (src) and.push(src);
if (dst) and.push(dst);
switch (and.length) {
case 0:
return undefined;
case 1:
return and[0];
default:
return {and};
}
}
/**
* Merges two loopback filters returning the merged filter.
*
* @param {Object} src The source filter
* @param {Object} dst The destination filter
* @return {Object} The result filter
*/
function mergeFilters(src, dst) {
let res = Object.assign({}, dst);
if (!src)
return res;
if (src.fields)
res.fields = mergeFields(src.fields, res.fields);
if (src.where)
res.where = mergeWhere(res.where, src.where);
if (src.include)
res.include = src.include;
if (src.order)
res.order = src.order;
if (src.limit)
res.limit = src.limit;
return res;
}
module.exports = {
fieldsToObject: fieldsToObject,
mergeFields: mergeFields,
mergeWhere: mergeWhere,
mergeFilters: mergeFilters
};