From ee73068e9eb2b3ddf6b3c4006ca80f56fa28a8a7 Mon Sep 17 00:00:00 2001 From: Juan Date: Thu, 18 Oct 2018 09:24:20 +0200 Subject: [PATCH] #659 - Unify model beta --- client/claim/src/action/index.html | 2 +- client/claim/src/basic-data/index.html | 2 +- client/claim/src/development/index.html | 8 +- client/client/src/billing-data/index.html | 2 +- client/client/src/sample/create/index.html | 2 +- .../src/components/array-model/array-model.js | 238 +++++++++++++++++ .../components/autocomplete/autocomplete.html | 3 +- .../components/autocomplete/autocomplete.js | 142 ++++++----- .../{rest-model => crud-model}/crud-model.js | 170 +++++-------- .../src/components/drop-down/drop-down.html | 4 - .../src/components/drop-down/drop-down.js | 239 ++++++++++++------ .../src/components/icon-menu/icon-menu.js | 56 ++-- client/core/src/components/index.js | 3 +- .../src/components/model-proxy/model-proxy.js | 222 ++++++++++------ client/core/src/components/popover/popover.js | 24 +- .../src/components/rest-model/rest-model.js | 127 ---------- .../components/rest-model/rest-model.spec.js | 34 --- .../lib/{asign-props.js => assign-props.js} | 0 client/core/src/lib/component.js | 29 ++- client/core/src/lib/event-emitter.js | 91 +++++++ client/item/src/create/index.html | 6 +- client/item/src/data/index.html | 2 +- client/item/src/tags/index.html | 17 +- client/item/src/tags/index.js | 6 + client/order/src/create/card.html | 2 +- client/ticket/src/package/index.html | 2 +- services/loopback/common/filter.js | 92 +++++++ 27 files changed, 951 insertions(+), 574 deletions(-) create mode 100644 client/core/src/components/array-model/array-model.js rename client/core/src/components/{rest-model => crud-model}/crud-model.js (65%) delete mode 100644 client/core/src/components/rest-model/rest-model.js delete mode 100644 client/core/src/components/rest-model/rest-model.spec.js rename client/core/src/lib/{asign-props.js => assign-props.js} (100%) create mode 100644 client/core/src/lib/event-emitter.js create mode 100644 services/loopback/common/filter.js diff --git a/client/claim/src/action/index.html b/client/claim/src/action/index.html index 7a1a1515f..7f4917c2d 100644 --- a/client/claim/src/action/index.html +++ b/client/claim/src/action/index.html @@ -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)"> diff --git a/client/claim/src/basic-data/index.html b/client/claim/src/basic-data/index.html index 774ebe315..5c7c12701 100644 --- a/client/claim/src/basic-data/index.html +++ b/client/claim/src/basic-data/index.html @@ -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"> {{firstName}} {{name}} diff --git a/client/claim/src/development/index.html b/client/claim/src/development/index.html index 0d462256f..d0b3d5ae9 100644 --- a/client/claim/src/development/index.html +++ b/client/claim/src/development/index.html @@ -49,7 +49,7 @@ id="claimReason" field="claimDevelopment.claimReasonFk" data="claimReasons" - select-fields="['id','description']" + fields="['id', 'description']" show-field="description" vn-acl="salesAssistant"> @@ -59,7 +59,7 @@ id="claimResult" field="claimDevelopment.claimResultFk" data="claimResults" - select-fields="['id','description']" + fields="['id', 'description']" show-field="description" vn-acl="salesAssistant"> @@ -69,7 +69,7 @@ id="Responsible" field="claimDevelopment.claimResponsibleFk" data="claimResponsibles" - select-fields="['id','description']" + fields="['id', 'description']" show-field="description" vn-acl="salesAssistant"> @@ -88,7 +88,7 @@ id="redelivery" field="claimDevelopment.claimRedeliveryFk" data="claimRedeliveries" - select-fields="['id','description']" + fields="['id', 'description']" show-field="description" vn-acl="salesAssistant"> diff --git a/client/client/src/billing-data/index.html b/client/client/src/billing-data/index.html index 86a7ef4e4..c42bb722d 100644 --- a/client/client/src/billing-data/index.html +++ b/client/client/src/billing-data/index.html @@ -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"> 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: ' + on-select="$ctrl.onDropDownSelect(value)" + on-data-ready="$ctrl.onDataReady()"> \ No newline at end of file diff --git a/client/core/src/components/autocomplete/autocomplete.js b/client/core/src/components/autocomplete/autocomplete.js index ffa353ea2..2d4e5facc 100755 --- a/client/core/src/components/autocomplete/autocomplete.js +++ b/client/core/src/components/autocomplete/autocomplete.js @@ -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,33 +106,32 @@ 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 (this.$.dropDown) { + let data; + if (this.$.dropDown.model) + data = this.$.dropDown.model.orgData; - if (!data && this.$.dropDown) - data = this.$.dropDown.$.model.data; + if (data) + for (let i = 0; i < data.length; i++) + if (data[i][this.valueField] === value) { + this.selection = data[i]; + return; + } - if (data) - for (let i = 0; i < data.length; i++) - if (data[i][this.valueField] === value) { - this.selection = data[i]; - 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: ' 0 || - update.length > 0 || - remove.length > 0; - - if (!isChanged) - return null; - let changes = { create: create, update: update, @@ -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: ' 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; -} diff --git a/client/core/src/components/drop-down/drop-down.html b/client/core/src/components/drop-down/drop-down.html index ce8d23a4f..9ec18044a 100755 --- a/client/core/src/components/drop-down/drop-down.html +++ b/client/core/src/components/drop-down/drop-down.html @@ -1,7 +1,3 @@ - - { - this.refreshModel(); - this.searchTimeout = null; - }, 350); + + 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(); + } + + 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']; +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: '|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: ' { + 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) { - row.$oldData = null; - row.$isNew = false; + let data = this.proxiedData; + if (data) + for (let row of data) + row.$oldData = null; + } + + 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; + + if (removed) { + removed = removed.sort((a, b) => a.$orgIndex - b.$orgIndex); + + for (let row of this.removed) + data.splice(row.$orgIndex, 0, row); + } this.resetChanges(); } - - dataChanged() { - if (this.onDataChange) - this.onDataChange(); - } } ngModule.component('vnModelProxy', { controller: ModelProxy, bindings: { orgData: ' 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 }); diff --git a/client/core/src/components/rest-model/rest-model.js b/client/core/src/components/rest-model/rest-model.js deleted file mode 100644 index f667bc69f..000000000 --- a/client/core/src/components/rest-model/rest-model.js +++ /dev/null @@ -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: ' { - 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(); - }); - }); -}); diff --git a/client/core/src/lib/asign-props.js b/client/core/src/lib/assign-props.js similarity index 100% rename from client/core/src/lib/asign-props.js rename to client/core/src/lib/assign-props.js diff --git a/client/core/src/lib/component.js b/client/core/src/lib/component.js index 36380a5ed..66ae63f70 100644 --- a/client/core/src/lib/component.js +++ b/client/core/src/lib/component.js @@ -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']; diff --git a/client/core/src/lib/event-emitter.js b/client/core/src/lib/event-emitter.js new file mode 100644 index 000000000..0876ddbc7 --- /dev/null +++ b/client/core/src/lib/event-emitter.js @@ -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']; diff --git a/client/item/src/create/index.html b/client/item/src/create/index.html index 895d120ee..822b03ca8 100644 --- a/client/item/src/create/index.html +++ b/client/item/src/create/index.html @@ -16,10 +16,10 @@ + search-function="{or: [{code: {regexp: $search}}, {name: {regexp: $search}}]}">
{{::code}}
{{::name}}
@@ -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}}]}">
{{::id}}
{{::description}}
diff --git a/client/item/src/data/index.html b/client/item/src/data/index.html index 7e939e811..c72a13dc3 100644 --- a/client/item/src/data/index.html +++ b/client/item/src/data/index.html @@ -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">
{{::id}}
diff --git a/client/item/src/tags/index.html b/client/item/src/tags/index.html index 05a98203e..5afd154e6 100644 --- a/client/item/src/tags/index.html +++ b/client/item/src/tags/index.html @@ -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"> @@ -17,17 +17,16 @@ Tags + vn-focus> {{itemFk}} : {{name}}
diff --git a/services/loopback/common/filter.js b/services/loopback/common/filter.js new file mode 100644 index 000000000..9f23b5330 --- /dev/null +++ b/services/loopback/common/filter.js @@ -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 +};