/* Copyright (c) 2010, Sage Software, Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import declare from 'dojo/_base/declare';
import lang from 'dojo/_base/lang';
import string from 'dojo/string';
import utility from '../Utility';
import _Field from './_Field';
import FieldManager from '../FieldManager';
import getResource from '../I18n';
const resource = getResource('lookupField');
/**
* @class argos.Fields.LookupField
* @classdesc The LookupField is similiar to an Edit View in that it is a field that takes the user to another
* view but the difference is that an EditorField takes the user to an Edit View, whereas LookupField
* takes the user to a List View.
*
* Meaning that LookupField is meant for establishing relationships by only storing the key for a value
* and with displayed text.
*
* @example
* {
* name: 'Owner',
* property: 'Owner',
* label: this.ownerText,
* type: 'lookup',
* view: 'user_list'
* }
* @extends argos.Fields._Field
* @requires argos.FieldManager
* @requires argos.Utility
*/
const control = declare('argos.Fields.LookupField', [_Field], /** @lends argos.Fields.LookupField# */{
/**
* @property {Object}
* Creates a setter map to html nodes, namely:
*
* * inputValue => inputNodes's value
* * inputDisabled => inputNodes's disabled
* * inputReadOnly => inputNodes readonly
*
*/
attributeMap: {
inputValue: {
node: 'inputNode',
type: 'attribute',
attribute: 'value',
},
inputDisabled: {
node: 'inputNode',
type: 'attribute',
attribute: 'disabled',
},
inputReadOnly: {
node: 'inputNode',
type: 'attribute',
attribute: 'readonly',
},
},
/**
* @property {Simplate}
* Simplate that defines the fields HTML Markup
*
* * `$` => Field instance
* * `$$` => Owner View instance
*
*/
widgetTemplate: new Simplate([
`{% if ($.label) { %}
<label for="{%= $.name %}"
{% if ($.required) { %}
class="required"
{% } %}>
{%: $.label %}
</label>
{% } %}
<div class="field field-control-wrapper">
<button class="field-control-trigger"
aria-label="{%: $.lookupLabelText %}"
data-action="buttonClick"
title="{%: $.lookupText %}">
<svg class="icon" focusable="false" aria-hidden="true" role="presentation">
<use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="#icon-{%: $.iconClass %}"></use>
</svg>
</button>
<input data-dojo-attach-point="inputNode"
type="text"
{% if ($.requireSelection) { %}
readonly="readonly"{% } %}
{% if ($.required) { %}
data-validate="required"
class="required"
{% } %}
/>
</div>`,
]),
iconClass: 'search',
// Localization
/**
* @property {String}
* Error text shown when validation fails.
*
* * `${0}` is the label text of the field
*/
dependentErrorText: resource.dependentErrorText,
/**
* @cfg {String}
* Text displayed when the field is cleared or set to null
*/
emptyText: resource.emptyText,
/**
* @deprecated
*/
completeText: resource.completeText,
/**
* @property {String}
* The ARIA label text in the lookup button
*/
lookupLabelText: resource.lookupLabelText,
/**
* @property {String}
* The text placed inside the lookup button
*/
lookupText: resource.lookupText,
/**
* @cfg {String}
* Required. Must be set to a view id of the target lookup view
*/
view: false,
/**
* @cfg {Object}
* Optional. Object to mixin over the view
*/
viewMixin: null,
/**
* @property {String}
* The default `valueKeyProperty` if `valueKeyProperty` is not defined.
*/
keyProperty: '$key',
/**
* required should be true if the field requires input. Defaults to false.
* @type {Boolean}
*/
required: false,
/**
* @property {String}
* The default `valueTextProperty` if `valueTextProperty` is not defined.
*/
textProperty: '$descriptor',
/**
* @cfg {Simplate}
* If provided the displayed textProperty is transformed with the given Simplate.
*
* * `$` => value extracted
* * `$$` => field instance
*
* Note that this (and renderer) are destructive, meaning once transformed the stored
* text value will be the result of the template/renderer. Typically this is not a concern
* as SData will only use the key property. But be aware if any other fields use this field as
* context.
*
*/
textTemplate: null,
/**
* @cfg {Function}
* If provided the displayed textProperty is transformed with the given function.
*
* The function is passed the current value and should return a string to be displayed.
*
* Note that this (and renderer) are destructive, meaning once transformed the stored
* text value will be the result of the template/renderer. Typically this is not a concern
* as SData will only use the key property. But be aware if any other fields use this field as
* context.
*
*/
textRenderer: null,
/**
* @cfg {String}
* The property name of the returned entry to use as the key
*/
valueKeyProperty: null,
/**
* @cfg {String}
* The property name of the returned entry to use as the displayed text/description
*/
valueTextProperty: null,
/**
* @cfg {Boolean}
* Flag that indicates the field is required and that a choice has to be made. If false,
* it passes the navigation option that {@link List List} views listen for for adding a "Empty"
* selection choice.
*/
requireSelection: true,
/**
* @property {Boolean}
* Sets the singleSelect navigation option and if true limits gather the value from the
* target list view to the first selection.
*/
singleSelect: true,
/**
* @property {String}
* The data-action of the toolbar item (which will be hidden) sent in navigation options. This
* with `singleSelect` is listened to in {@link List List} so clicking a row invokes the action,
* which is the function name defined (on the field instance in this case).
*/
singleSelectAction: 'complete',
/**
* @cfg {String}
* Name of the field in which this fields depends on before going to the target view. The value
* is retrieved from the dependOn field and passed to expandable navigation options (resourceKind,
* where, resourcePredicate and previousSelections).
*
* If dependsOn is set, the target field does not have a value and a user attempts to do a lookup
* an error will be shown.
*
*/
dependsOn: null,
/**
* @cfg {String/Function}
* May be set and passed in the navigation options to the target List view.
*
* Used to indicate the entity type.
*
*/
resourceKind: null,
/**
* @cfg {String/Function}
* May be set and passed in the navigation options to the target List view.
*/
resourcePredicate: null,
/**
* @cfg {String/Function}
* May be set and passed in the navigation options to the target List view.
*
* Sets the where expression used in the SData query of the List view.
*/
where: null,
/**
* @cfg {String/Function}
* May be set and passed in the navigation options to the target List view.
*
* Sets the orderBy expression used in the SData query of the List view.
*/
orderBy: null,
/**
* @property {Object}
* The current value object defined using the extracted key/text properties from the selected
* entry.
*/
currentValue: null,
/**
* @property {Object}
* The entire selected entry from the target view (not just the key/text properties).
*/
currentSelection: null,
/**
* Extends init to connect to the click event, if the field is read only disable and
* if require selection is false connect to onkeyup and onblur.
*/
init: function init() {
this.inherited(arguments);
this.connect(this.containerNode, 'onclick', this._onClick);
if (this.isReadOnly()) {
this.disable();
this.set('inputReadOnly', true);
} else if (!this.requireSelection) {
this.connect(this.inputNode, 'onkeyup', this._onKeyUp);
this.connect(this.inputNode, 'onblur', this._onBlur);
}
},
/**
* Extends enable to also remove the disabled attribute
*/
enable: function enable() {
this.inherited(arguments);
this.set('inputDisabled', false);
},
/**
* Extends disable to also set the disabled attribute
*/
disable: function disable() {
this.inherited(arguments);
this.set('inputDisabled', true);
},
focus: function focus() {
if (!this.isReadOnly()) {
this.inputNode.focus();
}
},
/**
* Determines if the field is readonly by checking for a target view
* @return {Boolean}
*/
isReadOnly: function isReadOnly() {
return !this.view;
},
/**
* Retrieves the value of the field named with `this.dependsOn`
* @return {String/Object/Number/Boolean}
*/
getDependentValue: function getDependentValue() {
if (this.dependsOn && this.owner) {
const field = this.owner.fields[this.dependsOn];
if (field) {
return field.getValue();
}
}
},
/**
* Retrieves the label string of the field named with `this.dependsOn`
* @return {String}
*/
getDependentLabel: function getDependentLabel() {
if (this.dependsOn && this.owner) {
const field = this.owner.fields[this.dependsOn];
if (field) {
return field.label;
}
}
},
/**
* Expands the passed expression if it is a function.
* @param {String/Function} expression Returns string directly, if function it is called and the result returned.
* @return {String} String expression.
*/
expandExpression: function expandExpression(expression) {
if (typeof expression === 'function') {
return expression.apply(this, Array.prototype.slice.call(arguments, 1));
}
return expression;
},
/**
* Creates the options to be passed in navigation to the target view
*
* Key points of the options set by default:
*
* * enableActions = false, List views should not be showing their list-actions bar this hides it
* * selectionOnly = true, List views should not allow editing/viewing, just selecting
* * negateHistory = true, disables saving of this options object when storing the history context
* * tools = {}, overrides the toolbar of the target view so that the function that fires is invoked
* in the context of this field, not the List.
*
* The following options are "expandable" meaning they can be strings or functions that return strings:
*
* resourceKind, resourcePredicate, where and previousSelections
*
* They will be passed the `dependsOn` field value (if defined).
*
*/
createNavigationOptions: function createNavigationOptions() {
const options = {
enableActions: false,
selectionOnly: true,
singleSelect: this.singleSelect,
singleSelectAction: this.singleSelectAction || 'complete',
allowEmptySelection: !this.requireSelection,
resourceKind: this.resourceKind,
resourcePredicate: this.resourcePredicate,
where: this.where,
orderBy: this.orderBy,
negateHistory: true,
continuousScrolling: false,
simpleMode: true,
tools: {
tbar: [{
id: 'complete',
svg: 'check',
fn: this.complete,
scope: this,
}, {
id: 'cancel',
side: 'left',
svg: 'cancel',
fn: this.reui.back,
scope: this.reui,
}],
},
};
const expand = ['resourceKind', 'resourcePredicate', 'where', 'previousSelections'];
const dependentValue = this.getDependentValue();
if (options.singleSelect && options.singleSelectAction) {
for (const key in options.tools.tbar) {
if (options.tools.tbar.hasOwnProperty(key)) {
const item = options.tools.tbar[key];
if (item.id === options.singleSelectAction) {
item.cls = 'display-none';
}
}
}
}
if (this.dependsOn && !dependentValue) {
console.error(string.substitute(this.dependentErrorText, [this.getDependentLabel() || '']));//eslint-disable-line
return false;
}
expand.forEach((item) => {
if (this[item]) {
options[item] = this.dependsOn // only pass dependentValue if there is a dependency
? this.expandExpression(this[item], dependentValue) : this.expandExpression(this[item]);
}
});
options.dependentValue = dependentValue;
options.title = this.title;
return options;
},
/**
* Navigates to the `this.view` id passing the options created from {@link #createNavigationOptions createNavigationOptions}.
*/
navigateToListView: function navigateToListView() {
const view = this.app.getView(this.view);
const options = this.createNavigationOptions();
if (view && options && !this.disabled) {
lang.mixin(view, this.viewMixin);
view.show(options);
}
},
buttonClick: function buttonClick() {
this.navigateToListView();
},
/**
* Handler for the click event, fires {@link #navigateToListView navigateToListView} if the
* field is not disabled.
* @param evt
*/
_onClick: function _onClick(evt) {
const buttonNode = $(evt.target).closest('.button').get(0);
if (!this.isDisabled() && (buttonNode || this.requireSelection)) {
evt.stopPropagation();
this.navigateToListView();
}
},
/**
* Handler for onkeyup, fires {@link #onNotificationTrigger onNotificationTrigger} if
* `this.notificationTrigger` is `'keyup'`.
* @param {Event} evt Click event
*/
_onKeyUp: function _onKeyUp(evt) {
if (!this.isDisabled() && this.notificationTrigger === 'keyup') {
this.onNotificationTrigger(evt);
}
},
/**
* Handler for onblur, fires {@link #onNotificationTrigger onNotificationTrigger} if
* `this.notificationTrigger` is `'blur'`.
* @param {Event} evt Blur event
*/
_onBlur: function _onBlur(evt) {
if (!this.isDisabled() && this.notificationTrigger === 'blur') {
this.onNotificationTrigger(evt);
}
},
/**
* Called from onkeyup and onblur handlers if the trigger is set.
*
* Checks the current value against `this.previousValue` and if different
* fires {@link #onChange onChange}.
*
* @param {Event} evt
*/
onNotificationTrigger: function onNotificationTrigger(/* evt*/) {
const currentValue = this.getValue();
if (this.previousValue !== currentValue) {
this.onChange(currentValue, this);
}
this.previousValue = currentValue;
},
/**
* Sets the displayed text of the field
* @param {String} text
*/
setText: function setText(text) {
this.set('inputValue', text);
this.previousValue = text;
},
/**
* Returns the string text of the field (note, not the value of the field)
* @return {String}
*/
getText: function getText() {
return this.inputNode.value;
},
/**
* Called from the target list view when a row is selected.
*
* The intent of the complete function is to gather the value(s) from the list view and
* transfer them to the field - then handle navigating back to the Edit view.
*
* The target view must be the currently active view and must have a selection model.
*
* The values are gathered and passed to {@link #setSelection setSelection}, `ReUI.back()` is
* fired and lastly {@link #_onComplete _onComplete} is called in a setTimeout due to bizarre
* transition issues, namely in IE.
*/
complete: function complete() {
const view = this.app.getPrimaryActiveView();
const selectionModel = view.get('selectionModel');
if (view && selectionModel) {
const selections = selectionModel.getSelections();
const selectionCount = selectionModel.getSelectionCount();
const unloadedSelections = view.getUnloadedSelections();
if (selectionCount === 0 && view.options.allowEmptySelection) {
this.clearValue(true);
}
if (this.singleSelect) {
for (const selectionKey in selections) {
if (selections.hasOwnProperty(selectionKey)) {
const val = selections[selectionKey].data;
this.setSelection(val, selectionKey);
break;
}
}
} else {
if (selectionCount > 0) {
this.setSelections(selections, unloadedSelections);
}
}
this.reui.back();
// if the event is fired before the transition, any XMLHttpRequest created in an event handler and
// executing during the transition can potentially fail (status 0). this might only be an issue with CORS
// requests created in this state (the pre-flight request is made, and the request ends with status 0).
// wrapping thing in a timeout and placing after the transition starts, mitigates this issue.
setTimeout(this._onComplete.bind(this), 0);
}
},
/**
* Forces {@link #onChange onChange} to fire
*/
_onComplete: function _onComplete() {
this.onChange(this.currentValue, this);
},
/**
* Determines if the field has been altered from the default/template value.
* @return {Boolean}
*/
isDirty: function isDirty() {
if (this.originalValue && this.currentValue) {
if (this.originalValue.key !== this.currentValue.key) {
return true;
}
if (this.originalValue.text !== this.currentValue.text) {
return true;
}
if (!this.requireSelection && !this.textTemplate) {
if (this.originalValue.text !== this.getText()) {
return true;
}
}
return false;
}
if (this.originalValue) {
if (!this.requireSelection && !this.textTemplate) {
if (this.originalValue.text !== this.getText()) {
return true;
}
}
} else {
if (!this.requireSelection && !this.textTemplate) {
const text = this.getText();
if (text && text.length > 0) {
return true;
}
}
}
return (this.originalValue !== this.currentValue);
},
/**
* Returns the current selection that was set from the target list view.
* @return {Object}
*/
getSelection: function getSelection() {
return this.currentSelection;
},
/**
* Returns the current value either by extracting the valueKeyProperty and valueTextProperty or
* several other methods of getting it to that state.
* @return {Object/String}
*/
getValue: function getValue() {
let value = null;
const text = this.getText() || '';
// if valueKeyProperty or valueTextProperty IS NOT EXPLICITLY set to false
// and IS NOT defined use keyProperty or textProperty in its place.
const keyProperty = this.valueKeyProperty !== false ? this.valueKeyProperty || this.keyProperty : false;
const textProperty = this.valueTextProperty !== false ? this.valueTextProperty || this.textProperty : false;
if (keyProperty || textProperty) {
if (this.currentValue) {
if (keyProperty) {
value = utility.setValue(value || {}, keyProperty, this.currentValue.key);
}
// if a text template has been applied there is no way to guarantee a correct
// mapping back to the property
if (textProperty && !this.textTemplate) {
value = utility.setValue(value || {}, textProperty, this.requireSelection ? this.currentValue.text : text);
}
} else if (!this.requireSelection) {
if (keyProperty && text.length > 0) {
value = utility.setValue(value || {}, keyProperty, text);
}
// if a text template has been applied there is no way to guarantee a correct
// mapping back to the property
if (textProperty && !this.textTemplate && text.length > 0) {
value = utility.setValue(value || {}, textProperty, text);
}
}
} else {
if (this.currentValue) {
if (this.requireSelection) {
value = this.currentValue.key ? this.currentValue.key : this.currentValue;
} else {
value = this.currentValue.text !== text && !this.textTemplate ? text : this.currentValue.key;
}
} else if (!this.requireSelection && text.length > 0) {
value = text;
}
}
return value;
},
/**
* If using a multi-select enabled lookup then the view will return multiple objects as the value.
*
* This function takes that array and returns the single value that should be used for `this.currentValue`.
*
* @template
* @param {Object[]} values
* @return {Object/String}
*/
formatValue: function formatValue(values) {
return values;
},
/**
* If using a multi-select enabled lookup this function will be called by {@link #complete complete}
* in that the target view returned multiple entries.
*
* Sets the currentValue using {@link #formatValue formatValue}.
*
* Sets the displayed text using `this.textRenderer`.
*
* @param {Object[]} values
* @param {Object[]} unloadedValues option.previousSelections that were not loaded by the view.
*/
setSelections: function setSelections(values, unloadedValues) {
this.currentValue = (this.formatValue) ? this.formatValue.call(this, values, unloadedValues) : values;
const text = (this.textRenderer) ? this.textRenderer.call(this, values, unloadedValues) : '';
this.setText(text);
},
/**
* If using a singleSelect enabled lookup this function will be called by {@link #complete complete}
* and the single entry's data and key will be passed to this function.
*
* Sets the `this.currentSelection` to the passed data (entire entry)
*
* Sets the `this.currentValue` to the extract key/text properties
*
* Calls {@link #setText setText} with the extracted text property.
*
* @param {Object} val Entire selection entry
* @param {String} key data-key attribute of the selected row (typically $key from SData)
*/
setSelection: function setSelection(val, key) {
this.currentSelection = val;
if (val === null || typeof val === 'undefined') {
return false;
}
let text = utility.getValue(val, this.textProperty);
const newKey = utility.getValue(val, this.keyProperty, val) || key; // if we can extract the key as requested, use it instead of the selection key
if (text && this.textTemplate) {
text = this.textTemplate.apply(text, this);
} else if (this.textRenderer) {
text = this.textRenderer.call(this, val, newKey, text);
}
this.currentValue = {
key: newKey || text,
text: text || newKey,
};
this.setText(this.currentValue.text);
},
/**
* Sets the given value to `this.currentValue` using the initial flag if to set it as
* clean/unmodified or false for dirty.
* @param {Object/String} val Value to set
* @param {Boolean} initial Dirty flag (true is clean)
*/
setValue: function setValue(val, initial) {
// if valueKeyProperty or valueTextProperty IS NOT EXPLICITLY set to false
// and IS NOT defined use keyProperty or textProperty in its place.
const keyProperty = this.valueKeyProperty !== false ? this.valueKeyProperty || this.keyProperty : false;
const textProperty = this.valueTextProperty !== false ? this.valueTextProperty || this.textProperty : false;
let key;
let text;
if (typeof val === 'undefined' || val === null) {
this.currentValue = false;
if (initial) {
this.originalValue = this.currentValue;
}
this.setText(this.requireSelection ? this.emptyText : '');
return false;
}
if (keyProperty || textProperty) {
if (keyProperty) {
key = utility.getValue(val, keyProperty);
}
if (textProperty) {
text = utility.getValue(val, textProperty);
}
if (text && this.textTemplate) {
text = this.textTemplate.apply(text, this);
} else if (this.textRenderer) {
text = this.textRenderer.call(this, val, key, text);
}
if (key || text) {
this.currentValue = {
key: key || text,
text: text || key,
};
if (initial) {
this.originalValue = this.currentValue;
}
this.setText(this.currentValue.text);
} else {
this.currentValue = false;
if (initial) {
this.originalValue = this.currentValue;
}
this.setText(this.requireSelection ? this.emptyText : '');
}
} else {
key = val;
text = val;
if (text && this.textTemplate) {
text = this.textTemplate.apply(text, this);
} else if (this.textRenderer) {
text = this.textRenderer.call(this, val, key, text);
}
this.currentValue = {
key: key || text,
text: text || key,
};
if (initial) {
this.originalValue = this.currentValue;
}
this.setText(text);
}
},
/**
* Clears the value by setting null (which triggers usage of `this.emptyText`.
*
* Flag is used to indicate if to set null as the initial value (unmodified) or not.
*
* @param {Boolean} flag
*/
clearValue: function clearValue(flag) {
const initial = flag !== true;
this.setSelection(null);
this.setValue(null, initial);
},
});
export default FieldManager.register('lookup', control);