import { A } from '@ember/array';
import { computed, observer, get, set } from '@ember/object';
import { copy } from '@ember/object/internals';
import { inject as service } from '@ember/service';
import { run } from '@ember/runloop';
import { isNone } from '@ember/utils';
import { debug, assert } from '@ember/debug';
import FlexberryBaseComponent from './flexberry-base-component';
import { getRelationType } from 'ember-flexberry-data/utils/model-functions';
import Builder from 'ember-flexberry-data/query/builder';
import Condition from 'ember-flexberry-data/query/condition';
import { BasePredicate, StringPredicate, ComplexPredicate } from 'ember-flexberry-data/query/predicate';
export default FlexberryBaseComponent.extend({
classNames: ['multiple-lookup'],
store: service(),
/**
* All records.
*/
records: null,
/**
* Count of dragover events.
*/
dragCount: 0,
/**
* Filtered records.
*/
filteredRecords: computed('records.@each.isDeleted', function () {
return this.get('records').filterBy('isDeleted', false);
}),
/**
* Draggable records.
*/
draggableRecords: computed('records.[]', function () {
return this.get('records').filterBy('isDeleted', false);
}),
/**
* New value path.
*/
newValuePropertyName: '',
/**
* Attribute name for tag value.
*/
tagDisplayAttributeName: null,
/**
* Value.
*/
value: null,
/**
* Lookup value input element.
*/
lookupInputElement: null,
/**
* Flag: all elements in one column.
*/
isColumnMode: false,
/**
* Minimum characters for autocomplete search.
*/
minCharacters: 1,
/**
* Maximum characters for autocomplete search.
*/
maxResults: 10,
/**
* Sorting direction.
*/
sorting: 'asc',
/**
* Lookup visibility.
*/
chooseComponentVisibility: true,
/**
* Lookup visibility based on readonly and visibility flags.
*/
isChooseComponentVisible: computed('chooseComponentVisibility', 'readonly', function () {
const isVisible = this.get('chooseComponentVisibility');
const readonly = this.get('readonly');
return isVisible && !readonly;
}),
/**
Default settings for tags.
@property defaultTagConfig
@type Object
@param {Boolean} [canBeDeleted=true] The tag can be deleted
@param {Boolean} [canBeSelected=true] The tag can be selected
@param {String} [customClass=''] Custom css classes for the tag
*/
defaultTagConfig: undefined,
/**
Hook for configurate tag.
@example
```handlebars
<!-- app/templates/employees.hbs -->
{{flexberry-multiple-lookup
...
configurateTag=(action "configurateTag")
...
}}
```
```js
// app/controllers/employees.js
import ListFormController from './list-form';
export default ListFormController.extend({
actions: {
configurateTag(tagConfig, record) {
set(tagConfig, 'canBeDeleted', false);
if (record === this.get('myFavoriteRecord')) {
set(tagConfig, 'canBeSelected', false);
set(tagConfig, 'customClass', 'my-fav-record');
}
}
}
});
```
@method configurateTag
@param {Object} tagConfig Settings for tag.
@param {DS.Model} record The record in tag.
*/
configurateTag: undefined,
/**
* Add new record by value.
*/
addNewRecordByValue() {
this.addNewRecord('value');
},
/**
* Initialization of adding a record by field relatedModel.{relationName}.
*/
initAddNewRecordByNewValuePropertyName() {
run.debounce(this, this.addNewRecordByNewValuePropertyName, 500);
},
/**
* Adding a record by field relatedModel.{relationName}.
*/
addNewRecordByNewValuePropertyName() {
this.addNewRecord(this.get('newValuePropertyName'));
},
/**
* Adding a record.
*
* @param propertyPath
*/
addNewRecord(propertyPath) {
const newValue = this.get(propertyPath);
if (!isNone(newValue)) {
if (isNone(this.get('filteredRecords').findBy(`${this.get('relationName')}.id`, get(newValue, 'id')))) {
run.next(() => {
this.add(newValue);
});
}
this.set(propertyPath, null);
}
},
/**
Creates an instance of the Builder class with selection and sorting specified in the component parameters.
*
@function _createQueryBuilder
@param {DS.Store} store
@param {string} modelName
@param {string} projection
@param {string} order
@returns {Builder}
*/
_createQueryBuilder(store, modelName, projection, order) {
const sorting = this.get('sorting');
const displayAttributeName = this.get('displayAttributeName');
const orderAttributeName = this.get('orderAttributeName');
const builder = new Builder(store, modelName);
if (projection) {
builder.selectByProjection(projection);
} else {
builder.select(displayAttributeName);
}
builder.orderBy(`${order ? order : `${orderAttributeName ? orderAttributeName : displayAttributeName} ${sorting}`}`);
return builder;
},
/**
Concatenates predicates.
*
@function _conjuctPredicates
@param {BasePredicate} limitPredicate The first predicate to concatenate.
@param {BasePredicate} autocompletePredicate The second predicate to concatenate.
@param {Function} lookupAdditionalLimitFunction Function return BasePredicate to concatenate.
@returns {BasePredicate} Concatenation of two predicates.
@throws {Error} Throws error if any of parameter predicates has wrong type.
*/
_conjuctPredicates(limitPredicate, lookupAdditionalLimitFunction, autocompletePredicate) {
const limitArray = A();
if (limitPredicate) {
if (limitPredicate instanceof BasePredicate) {
limitArray.pushObject(limitPredicate);
} else {
throw new Error('Limit predicate is not correct. It has to be instance of BasePredicate.');
}
}
if (autocompletePredicate) {
if (autocompletePredicate instanceof BasePredicate) {
limitArray.pushObject(autocompletePredicate);
} else {
throw new Error('Autocomplete predicate is not correct. It has to be instance of BasePredicate.');
}
}
if (lookupAdditionalLimitFunction) {
if ((lookupAdditionalLimitFunction instanceof Function)) {
const compileAdditionakBasePredicate = lookupAdditionalLimitFunction(this.get('relatedModel'));
if (compileAdditionakBasePredicate) {
if (compileAdditionakBasePredicate instanceof BasePredicate) {
limitArray.pushObject(compileAdditionakBasePredicate);
} else {
throw new Error('lookupAdditionalLimitFunction must return BasePredicate.');
}
}
} else {
throw new Error('lookupAdditionalLimitFunction must to be function.');
}
}
if (limitArray.length > 1) {
return new ComplexPredicate(Condition.And, ...limitArray);
} else {
return limitArray[0];
}
},
/**
* Swap element in records by id.
*
* @function _swapRecordElementsById
* @param {string} activeElementId Id of element to move.
* @param {string} currentElementId Id of element for insert position.
*/
_swapRecordElementsById(activeElementId, currentElementId) {
const relationName = this.get('relationName');
let records = this.get('records');
let activeElement = records.findBy(relationName + '.id', activeElementId);
let currentElement = records.findBy(relationName + '.id', currentElementId);
let activeElementIndex = records.indexOf(activeElement);
let increaseIndex = activeElementIndex < records.indexOf(currentElement) ? 1 : 0;
records.removeObject(activeElement);
let newIndex = records.indexOf(currentElement);
records.insertAt(newIndex + increaseIndex, activeElement);
this.set('records', records);
this.set('dragCount', this.get('dragCount') + 1);
},
/**
* Building a property to display selected values.
*/
buildDisplayValue: observer('filteredRecords.[]', 'tagDisplayAttributeName', 'relationName', 'displayAttributeName', function () {
this.get('filteredRecords').forEach((record) => {
set(record, 'tagDisplayValue', this.getRecordDisplayValue(record));
set(record, 'tagIdValue', get(record, this.get('relationName') + '.id'));
});
}),
/**
* Building a tag configuration property.
*/
buildConfigurateTags: observer('filteredRecords.[]', 'tagDisplayAttributeName', 'relationName', 'displayAttributeName', function () {
let configurateTag = this.get('configurateTag');
this.get('filteredRecords').forEach((record) => {
let tagConfig = copy(this.get('defaultTagConfig'));
set(record, `${this.get('componentName')}_tagConfig`, tagConfig);
if (configurateTag) {
assert('configurateTag must be a function', typeof configurateTag === 'function');
configurateTag(tagConfig, record);
}
});
}),
/**
Object with lookup properties to send on remove action.
*
@property removeData
@type Object
@readOnly
*/
removeData: computed('relationName', 'relatedModel', function () {
return {
relationName: this.get('relationName'),
modelToLookup: this.get('relatedModel')
};
}),
/**
* Get the value to display in the tag for the list item.
*
* @param record
*/
getRecordDisplayValue(record) {
const tagDisplayAttributeName = this.get('tagDisplayAttributeName');
return !isNone(tagDisplayAttributeName)
? get(record, tagDisplayAttributeName)
: get(record, this.get('relationName') + '.' + this.get('displayAttributeName'));
},
init() {
this._super(...arguments);
const newValuePropertyName = 'relatedModel.' + this.get('relationName');
this.set('newValuePropertyName', newValuePropertyName);
this.set('value', null);
this.get('filteredRecords').forEach((record) => {
set(record, 'tagDisplayValue', this.getRecordDisplayValue(record));
set(record, 'tagIdValue', get(record, this.get('relationName') + '.id'));
});
this.addObserver('value', this.addNewRecordByValue);
this.addObserver(newValuePropertyName, this.initAddNewRecordByNewValuePropertyName);
this.set('defaultTagConfig', {
canBeDeleted: true,
canBeSelected: true,
customClass: '',
});
this.buildConfigurateTags();
},
actions: {
updateLookupValue(updateData) {
this.get('currentController').send('updateLookupValue', updateData);
},
showLookupDialog(chooseData) {
this.get('currentController').send('showLookupDialog', chooseData);
},
delete(record) {
this.delete(record);
},
preview(record) {
this.preview(record);
},
},
didRender() {
const _this = this;
const store = this.get('store');
const relatedModel = this.get('relatedModel');
const relationName = this.get('relationName');
if (isNone(relationName)) {
// We consider that the component works in block form.
return;
}
const relationModelName = getRelationType(relatedModel, relationName);
const displayAttributeName = this.get('displayAttributeName');
if (!displayAttributeName) {
throw new Error('`displayAttributeName` is required property for autocomplete mode in `flexberry-lookup`.');
}
const minCharacters = this.get('minCharacters');
if (!minCharacters || typeof (minCharacters) !== 'number' || minCharacters <= 0) {
throw new Error('minCharacters has wrong value.');
}
const maxResults = this.get('maxResults');
if (!maxResults || typeof (maxResults) !== 'number' || maxResults <= 0) {
throw new Error('maxResults has wrong value.');
}
// Add select first autocomplete result by enter click.
this.$('input').keyup(function (event) {
if (event.keyCode === 13) {
const result = _this.$('div.results.transition.visible');
const activeField = result.children('a.result.active');
const resultField = result.children('a.result')[0];
if (resultField && activeField.length === 0) {
resultField.click();
}
}
});
let state;
const i18n = this.get('i18n');
this.$('.ui.search').search({
minCharacters: minCharacters,
maxResults: maxResults + 1,
cache: false,
templates: {
message: function () {
return '<div class="message empty"><div class="header">' +
i18n.t('components.flexberry-lookup.dropdown.messages.noResultsHeader').string +
'</div><div class="description">' +
i18n.t('components.flexberry-lookup.dropdown.messages.noResults').string +
'</div></div>';
}
},
apiSettings: {
/**
Mocks call to the data source,
Uses query language and store for loading data explicitly.
*
@param {object} settings
@param {Function} callback
*/
responseAsync(settings, callback) {
// Prevent async data-request from being sent in readonly mode.
if (get(_this, 'readonly')) {
return;
}
const autocompleteProjection = get(_this, 'autocompleteProjection');
const autocompleteOrder = get(_this, 'autocompleteOrder');
const builder = _this._createQueryBuilder(store, relationModelName, autocompleteProjection, autocompleteOrder);
const autocompletePredicate = settings.urlData.query ?
new StringPredicate(displayAttributeName).contains(settings.urlData.query) :
undefined;
const resultPredicate =
_this._conjuctPredicates(get(_this, 'lookupLimitPredicate'), get(_this, 'lookupAdditionalLimitFunction'), autocompletePredicate);
if (resultPredicate) {
builder.where(resultPredicate);
}
const maxRes = get(_this, 'maxResults');
let iCount = 1;
builder.top(maxRes + 1);
builder.count();
run(() => {
store.query(relationModelName, builder.build()).then((records) => {
callback({
success: true,
results: records.map(i => {
const attributeName = get(i, displayAttributeName);
if (iCount > maxRes && records.meta.count > maxRes) {
return {
title: '...'
};
} else {
iCount += 1;
return {
title: attributeName,
instance: i
};
}
})
});
}, () => {
callback({ success: false });
});
});
}
},
/**
* Handles opening of the autocomplete list.
* Sets current state (taht autocomplete list is opened) for future purposes.
*/
onResultsOpen() {
state = 'opened';
run(() => {
debug(`Flexberry Lookup::autocomplete state = ${state}`);
});
},
/**
* Handles selection of item from the autocomplete list.
* Saves selected model and notifies the controller.
*
* @param {object} result Item from array of objects, built in `responseAsync`.
*/
onSelect(result) {
state = 'selected';
debug(`Flexberry Lookup::autocomplete state = ${state}; result = ${result}`);
set(_this, 'value', result.instance);
// Removing focus is necessary to clear the text in the input field.
_this.$('input').blur();
_this.$('.ui.search').search('set value', '');
_this.$('input').focus();
return false;
},
/**
* Handles closing of the autocomplete list.
* Restores display text if nothing has been selected.
*/
onResultsClose() {
// Set displayValue directly because value hasn'been changes
// and Ember won't change computed property.
_this.$('.ui.search').search('set value', '');
state = 'closed';
run(() => {
debug(`Flexberry Lookup::autocomplete state = ${state}`);
});
}
});
// List of tags.
let tagsListElement = this.$('.draggable-tags')[0];
// Dragstart event.
tagsListElement.addEventListener('dragstart', (evt) => {
evt.target.classList.add('selected');
})
// Dragend event.
tagsListElement.addEventListener('dragend', (evt) => {
let currentElement = evt.target;
if (!currentElement.classList.contains('selected')) return;
currentElement.classList.remove('selected');
});
// Dragover event.
tagsListElement.addEventListener('dragover', (evt) => {
// Allow to drop.
evt.preventDefault();
// Find moving element.
const activeElement = tagsListElement.querySelector('.selected');
if (activeElement === null) return;
// Current element.
var currentElement = evt.target;
// Take tag element.
while (!currentElement.classList.contains('draggable-tag')) {
currentElement = currentElement.parentElement;
if (!currentElement) return;
}
if (activeElement === currentElement) return;
const activeElementId = activeElement.getAttribute('id');
const currentElementId = currentElement.getAttribute('id');
// Swap elements.
_this._swapRecordElementsById(activeElementId, currentElementId);
});
}
});