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 || '&nbsp;'">
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