1import { debounce, find, indexOf, map, isObject, escape, unescape } from 'lodash'; 2import coreModule from '../../core_module'; 3import { ISCEService } from 'angular'; 4import { promiseToDigest } from '../../promiseToDigest'; 5 6function typeaheadMatcher(this: any, item: string) { 7 let str = this.query; 8 if (str === '') { 9 return true; 10 } 11 if (str[0] === '/') { 12 str = str.substring(1); 13 } 14 if (str[str.length - 1] === '/') { 15 str = str.substring(0, str.length - 1); 16 } 17 return item.toLowerCase().match(str.toLowerCase()); 18} 19 20export class FormDropdownCtrl { 21 inputElement: JQLite; 22 linkElement: JQLite; 23 model: any; 24 display: any; 25 text: any; 26 options: any; 27 cssClass: any; 28 cssClasses: any; 29 allowCustom: any; 30 labelMode: boolean; 31 linkMode: boolean; 32 cancelBlur: any; 33 onChange: any; 34 getOptions: any; 35 optionCache: any; 36 lookupText: boolean; 37 placeholder: any; 38 startOpen: any; 39 debounce: boolean; 40 41 /** @ngInject */ 42 constructor(private $scope: any, $element: JQLite, private $sce: ISCEService, private templateSrv: any) { 43 this.inputElement = $element.find('input').first(); 44 this.linkElement = $element.find('a').first(); 45 this.linkMode = true; 46 this.cancelBlur = null; 47 this.labelMode = false; 48 this.lookupText = false; 49 this.debounce = false; 50 51 // listen to model changes 52 $scope.$watch('ctrl.model', this.modelChanged.bind(this)); 53 } 54 55 $onInit() { 56 if (this.labelMode) { 57 this.cssClasses = 'gf-form-label ' + this.cssClass; 58 } else { 59 this.cssClasses = 'gf-form-input gf-form-input--dropdown ' + this.cssClass; 60 } 61 62 if (this.placeholder) { 63 this.inputElement.attr('placeholder', this.placeholder); 64 } 65 66 this.inputElement.attr('data-provide', 'typeahead'); 67 this.inputElement.typeahead({ 68 source: this.typeaheadSource.bind(this), 69 minLength: 0, 70 items: 10000, 71 updater: this.typeaheadUpdater.bind(this), 72 matcher: typeaheadMatcher, 73 }); 74 75 // modify typeahead lookup 76 // this = typeahead 77 const typeahead = this.inputElement.data('typeahead'); 78 typeahead.lookup = function () { 79 this.query = this.$element.val() || ''; 80 this.source(this.query, this.process.bind(this)); 81 }; 82 83 if (this.debounce) { 84 typeahead.lookup = debounce(typeahead.lookup, 500, { leading: true }); 85 } 86 87 this.linkElement.keydown((evt) => { 88 // trigger typeahead on down arrow or enter key 89 if (evt.keyCode === 40 || evt.keyCode === 13) { 90 this.linkElement.click(); 91 } 92 }); 93 94 this.inputElement.keydown((evt) => { 95 if (evt.keyCode === 13) { 96 setTimeout(() => { 97 this.inputElement.blur(); 98 }, 300); 99 } 100 }); 101 102 this.inputElement.blur(this.inputBlur.bind(this)); 103 104 if (this.startOpen) { 105 setTimeout(this.open.bind(this), 0); 106 } 107 } 108 109 getOptionsInternal(query: string) { 110 return promiseToDigest(this.$scope)(Promise.resolve(this.getOptions({ $query: query }))); 111 } 112 113 isPromiseLike(obj: any) { 114 return obj && typeof obj.then === 'function'; 115 } 116 117 modelChanged() { 118 if (isObject(this.model)) { 119 this.updateDisplay((this.model as any).text); 120 } else { 121 // if we have text use it 122 if (this.lookupText) { 123 this.getOptionsInternal('').then((options: any) => { 124 const item: any = find(options, { value: this.model }); 125 this.updateDisplay(item ? item.text : this.model); 126 }); 127 } else { 128 this.updateDisplay(this.model); 129 } 130 } 131 } 132 133 typeaheadSource(query: string, callback: (res: any) => void) { 134 this.getOptionsInternal(query).then((options: any) => { 135 this.optionCache = options; 136 137 // extract texts 138 const optionTexts = map(options, (op: any) => { 139 return escape(op.text); 140 }); 141 142 // add custom values 143 if (this.allowCustom && this.text !== '') { 144 if (indexOf(optionTexts, this.text) === -1) { 145 optionTexts.unshift(this.text); 146 } 147 } 148 149 callback(optionTexts); 150 }); 151 } 152 153 typeaheadUpdater(text: string) { 154 if (text === this.text) { 155 clearTimeout(this.cancelBlur); 156 this.inputElement.focus(); 157 return text; 158 } 159 160 this.inputElement.val(text); 161 this.switchToLink(true); 162 return text; 163 } 164 165 switchToLink(fromClick: boolean) { 166 if (this.linkMode && !fromClick) { 167 return; 168 } 169 170 clearTimeout(this.cancelBlur); 171 this.cancelBlur = null; 172 this.linkMode = true; 173 this.inputElement.hide(); 174 this.linkElement.show(); 175 this.updateValue(this.inputElement.val() as string); 176 } 177 178 inputBlur() { 179 // happens long before the click event on the typeahead options 180 // need to have long delay because the blur 181 this.cancelBlur = setTimeout(this.switchToLink.bind(this), 200); 182 } 183 184 updateValue(text: string) { 185 text = unescape(text); 186 187 if (text === '' || this.text === text) { 188 return; 189 } 190 191 this.$scope.$apply(() => { 192 const option: any = find(this.optionCache, { text: text }); 193 194 if (option) { 195 if (isObject(this.model)) { 196 this.model = option; 197 } else { 198 this.model = option.value; 199 } 200 this.text = option.text; 201 } else if (this.allowCustom) { 202 if (isObject(this.model)) { 203 (this.model as any).text = (this.model as any).value = text; 204 } else { 205 this.model = text; 206 } 207 this.text = text; 208 } 209 210 // needs to call this after digest so 211 // property is synced with outerscope 212 this.$scope.$$postDigest(() => { 213 this.$scope.$apply(() => { 214 this.onChange({ $option: option }); 215 }); 216 }); 217 }); 218 } 219 220 updateDisplay(text: string) { 221 this.text = text; 222 this.display = this.$sce.trustAsHtml(this.templateSrv.highlightVariablesAsHtml(text)); 223 } 224 225 open() { 226 this.inputElement.css('width', Math.max(this.linkElement.width()!, 80) + 16 + 'px'); 227 228 this.inputElement.show(); 229 this.inputElement.focus(); 230 231 this.linkElement.hide(); 232 this.linkMode = false; 233 234 const typeahead = this.inputElement.data('typeahead'); 235 if (typeahead) { 236 this.inputElement.val(''); 237 typeahead.lookup(); 238 } 239 } 240} 241 242const template = ` 243<input type="text" 244 data-provide="typeahead" 245 class="gf-form-input" 246 spellcheck="false" 247 style="display:none"> 248</input> 249<a ng-class="ctrl.cssClasses" 250 tabindex="1" 251 ng-click="ctrl.open()" 252 give-focus="ctrl.focus" 253 ng-bind-html="ctrl.display || ' '"> 254</a> 255`; 256 257export function formDropdownDirective() { 258 return { 259 restrict: 'E', 260 template: template, 261 controller: FormDropdownCtrl, 262 bindToController: true, 263 controllerAs: 'ctrl', 264 scope: { 265 model: '=', 266 getOptions: '&', 267 onChange: '&', 268 cssClass: '@', 269 allowCustom: '@', 270 labelMode: '@', 271 lookupText: '@', 272 placeholder: '@', 273 startOpen: '@', 274 debounce: '@', 275 }, 276 }; 277} 278 279coreModule.directive('gfFormDropdown', formDropdownDirective); 280