gfm_auto_complete.js.es6 13.4 KB
Newer Older
1
/* eslint-disable func-names, space-before-function-paren, no-template-curly-in-string, comma-dangle, object-shorthand, quotes, dot-notation, no-else-return, one-var, no-var, no-underscore-dangle, one-var-declaration-per-line, no-param-reassign, no-useless-escape, prefer-template, consistent-return, wrap-iife, prefer-arrow-callback, camelcase, no-unused-vars, no-useless-return, vars-on-top, max-len */
2

3
// Creates the variables for setting up GFM auto-completion
Fatih Acet's avatar
Fatih Acet committed
4
(function() {
5 6
  if (window.gl == null) {
    window.gl = {};
Fatih Acet's avatar
Fatih Acet committed
7 8
  }

9 10 11 12
  function sanitize(str) {
    return str.replace(/<(?:.|\n)*?>/gm, '');
  }

13 14 15
  window.gl.GfmAutoComplete = {
    dataSources: {},
    defaultLoadingData: ['loading'],
Fatih Acet's avatar
Fatih Acet committed
16
    cachedData: {},
17 18 19 20 21 22 23 24 25 26
    isLoadingData: {},
    atTypeMap: {
      ':': 'emojis',
      '@': 'members',
      '#': 'issues',
      '!': 'mergeRequests',
      '~': 'labels',
      '%': 'milestones',
      '/': 'commands'
    },
27
    // Emoji
Fatih Acet's avatar
Fatih Acet committed
28 29 30
    Emoji: {
      template: '<li>${name} <img alt="${name}" height="20" src="${path}" width="20" /></li>'
    },
31
    // Team Members
Fatih Acet's avatar
Fatih Acet committed
32
    Members: {
33
      template: '<li>${avatarTag} ${username} <small>${title}</small></li>'
Fatih Acet's avatar
Fatih Acet committed
34 35 36 37
    },
    Labels: {
      template: '<li><span class="dropdown-label-box" style="background: ${color}"></span> ${title}</li>'
    },
38
    // Issues and MergeRequests
Fatih Acet's avatar
Fatih Acet committed
39 40 41
    Issues: {
      template: '<li><small>${id}</small> ${title}</li>'
    },
42
    // Milestones
Fatih Acet's avatar
Fatih Acet committed
43 44 45 46
    Milestones: {
      template: '<li>${title}</li>'
    },
    Loading: {
47
      template: '<li style="pointer-events: none;"><i class="fa fa-refresh fa-spin"></i> Loading...</li>'
Fatih Acet's avatar
Fatih Acet committed
48 49 50
    },
    DefaultOptions: {
      sorter: function(query, items, searchKey) {
51
        this.setting.highlightFirst = this.setting.alwaysHighlightFirst || query.length > 0;
52
        if (gl.GfmAutoComplete.isLoading(items)) {
53
          this.setting.highlightFirst = false;
Fatih Acet's avatar
Fatih Acet committed
54 55 56 57 58
          return items;
        }
        return $.fn.atwho["default"].callbacks.sorter(query, items, searchKey);
      },
      filter: function(query, data, searchKey) {
59 60
        if (gl.GfmAutoComplete.isLoading(data)) {
          gl.GfmAutoComplete.fetchData(this.$inputor, this.at);
Fatih Acet's avatar
Fatih Acet committed
61
          return data;
62 63
        } else {
          return $.fn.atwho["default"].callbacks.filter(query, data, searchKey);
Fatih Acet's avatar
Fatih Acet committed
64 65 66
        }
      },
      beforeInsert: function(value) {
67 68 69 70
        if (value && !this.setting.skipSpecialCharacterTest) {
          var withoutAt = value.substring(1);
          if (withoutAt && /[^\w\d]/.test(withoutAt)) value = value.charAt() + '"' + withoutAt + '"';
        }
71
        return value;
72 73 74 75 76
      },
      matcher: function (flag, subtext) {
        // The below is taken from At.js source
        // Tweaked to commands to start without a space only if char before is a non-word character
        // https://github.com/ichord/At.js
77 78 79
        var _a, _y, regexp, match, atSymbolsWithBar, atSymbolsWithoutBar;
        atSymbolsWithBar = Object.keys(this.app.controllers).join('|');
        atSymbolsWithoutBar = Object.keys(this.app.controllers).join('');
80
        subtext = subtext.split(/\s+/g).pop();
81 82 83 84 85
        flag = flag.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");

        _a = decodeURI("%C3%80");
        _y = decodeURI("%C3%BF");

86
        regexp = new RegExp("^(?:\\B|[^a-zA-Z0-9_" + atSymbolsWithoutBar + "]|\\s)" + flag + "(?!" + atSymbolsWithBar + ")((?:[A-Za-z" + _a + "-" + _y + "0-9_\'\.\+\-]|[^\\x00-\\x7a])*)$", 'gi');
87 88 89 90

        match = regexp.exec(subtext);

        if (match) {
91
          return match[1];
92 93 94
        } else {
          return null;
        }
Fatih Acet's avatar
Fatih Acet committed
95 96
      }
    },
97
    setup: function(input) {
98
      // Add GFM auto-completion to all input fields, that accept GFM input.
99
      this.input = input || $('.js-gfm-input');
100 101 102 103 104 105
      this.setupLifecycle();
    },
    setupLifecycle() {
      this.input.each((i, input) => {
        const $input = $(input);
        $input.off('focus.setupAtWho').on('focus.setupAtWho', this.setupAtWho.bind(this, $input));
106 107 108
        // This triggers at.js again
        // Needed for slash commands with suffixes (ex: /label ~)
        $input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup'));
109 110 111
      });
    },
    setupAtWho: function($input) {
112
      // Emoji
113
      $input.atwho({
Fatih Acet's avatar
Fatih Acet committed
114
        at: ':',
115 116 117
        displayTpl: function(value) {
          return value.path != null ? this.Emoji.template : this.Loading.template;
        }.bind(this),
Fatih Acet's avatar
Fatih Acet committed
118
        insertTpl: ':${name}:',
119
        skipSpecialCharacterTest: true,
120
        data: this.defaultLoadingData,
Fatih Acet's avatar
Fatih Acet committed
121 122
        callbacks: {
          sorter: this.DefaultOptions.sorter,
123
          beforeInsert: this.DefaultOptions.beforeInsert,
124
          filter: this.DefaultOptions.filter
Fatih Acet's avatar
Fatih Acet committed
125 126
        }
      });
127
      // Team Members
128
      $input.atwho({
Fatih Acet's avatar
Fatih Acet committed
129
        at: '@',
130 131 132
        displayTpl: function(value) {
          return value.username != null ? this.Members.template : this.Loading.template;
        }.bind(this),
Fatih Acet's avatar
Fatih Acet committed
133 134
        insertTpl: '${atwho-at}${username}',
        searchKey: 'search',
135
        alwaysHighlightFirst: true,
136
        skipSpecialCharacterTest: true,
137
        data: this.defaultLoadingData,
Fatih Acet's avatar
Fatih Acet committed
138 139 140 141
        callbacks: {
          sorter: this.DefaultOptions.sorter,
          filter: this.DefaultOptions.filter,
          beforeInsert: this.DefaultOptions.beforeInsert,
142
          matcher: this.DefaultOptions.matcher,
Fatih Acet's avatar
Fatih Acet committed
143 144
          beforeSave: function(members) {
            return $.map(members, function(m) {
145
              let title = '';
Fatih Acet's avatar
Fatih Acet committed
146 147 148 149 150 151 152
              if (m.username == null) {
                return m;
              }
              title = m.name;
              if (m.count) {
                title += " (" + m.count + ")";
              }
153 154 155 156 157

              const autoCompleteAvatar = m.avatar_url || m.username.charAt(0).toUpperCase();
              const imgAvatar = `<img src="${m.avatar_url}" alt="${m.username}" class="avatar avatar-inline center s26"/>`;
              const txtAvatar = `<div class="avatar center avatar-inline s26">${autoCompleteAvatar}</div>`;

Fatih Acet's avatar
Fatih Acet committed
158 159
              return {
                username: m.username,
160
                avatarTag: autoCompleteAvatar.length === 1 ? txtAvatar : imgAvatar,
161 162
                title: sanitize(title),
                search: sanitize(m.username + " " + m.name)
Fatih Acet's avatar
Fatih Acet committed
163 164 165 166 167
              };
            });
          }
        }
      });
168
      $input.atwho({
Fatih Acet's avatar
Fatih Acet committed
169 170 171
        at: '#',
        alias: 'issues',
        searchKey: 'search',
172 173 174 175
        displayTpl: function(value) {
          return value.title != null ? this.Issues.template : this.Loading.template;
        }.bind(this),
        data: this.defaultLoadingData,
Fatih Acet's avatar
Fatih Acet committed
176 177 178 179 180
        insertTpl: '${atwho-at}${id}',
        callbacks: {
          sorter: this.DefaultOptions.sorter,
          filter: this.DefaultOptions.filter,
          beforeInsert: this.DefaultOptions.beforeInsert,
181
          matcher: this.DefaultOptions.matcher,
Fatih Acet's avatar
Fatih Acet committed
182 183 184 185 186 187 188
          beforeSave: function(issues) {
            return $.map(issues, function(i) {
              if (i.title == null) {
                return i;
              }
              return {
                id: i.iid,
189
                title: sanitize(i.title),
Fatih Acet's avatar
Fatih Acet committed
190 191 192 193 194 195
                search: i.iid + " " + i.title
              };
            });
          }
        }
      });
196
      $input.atwho({
Fatih Acet's avatar
Fatih Acet committed
197 198 199
        at: '%',
        alias: 'milestones',
        searchKey: 'search',
200
        insertTpl: '${atwho-at}${title}',
201 202 203 204
        displayTpl: function(value) {
          return value.title != null ? this.Milestones.template : this.Loading.template;
        }.bind(this),
        data: this.defaultLoadingData,
Fatih Acet's avatar
Fatih Acet committed
205
        callbacks: {
206
          matcher: this.DefaultOptions.matcher,
Yann Gravrand's avatar
Yann Gravrand committed
207
          sorter: this.DefaultOptions.sorter,
208
          beforeInsert: this.DefaultOptions.beforeInsert,
209
          filter: this.DefaultOptions.filter,
Fatih Acet's avatar
Fatih Acet committed
210 211 212 213 214 215 216
          beforeSave: function(milestones) {
            return $.map(milestones, function(m) {
              if (m.title == null) {
                return m;
              }
              return {
                id: m.iid,
217
                title: sanitize(m.title),
Fatih Acet's avatar
Fatih Acet committed
218 219 220 221 222 223
                search: "" + m.title
              };
            });
          }
        }
      });
224
      $input.atwho({
Fatih Acet's avatar
Fatih Acet committed
225 226 227
        at: '!',
        alias: 'mergerequests',
        searchKey: 'search',
228 229 230 231
        displayTpl: function(value) {
          return value.title != null ? this.Issues.template : this.Loading.template;
        }.bind(this),
        data: this.defaultLoadingData,
Fatih Acet's avatar
Fatih Acet committed
232 233 234 235 236
        insertTpl: '${atwho-at}${id}',
        callbacks: {
          sorter: this.DefaultOptions.sorter,
          filter: this.DefaultOptions.filter,
          beforeInsert: this.DefaultOptions.beforeInsert,
237
          matcher: this.DefaultOptions.matcher,
Fatih Acet's avatar
Fatih Acet committed
238 239 240 241 242 243 244
          beforeSave: function(merges) {
            return $.map(merges, function(m) {
              if (m.title == null) {
                return m;
              }
              return {
                id: m.iid,
245
                title: sanitize(m.title),
Fatih Acet's avatar
Fatih Acet committed
246 247 248 249 250 251
                search: m.iid + " " + m.title
              };
            });
          }
        }
      });
252
      $input.atwho({
Fatih Acet's avatar
Fatih Acet committed
253 254 255
        at: '~',
        alias: 'labels',
        searchKey: 'search',
256 257 258 259
        data: this.defaultLoadingData,
        displayTpl: function(value) {
          return this.isLoading(value) ? this.Loading.template : this.Labels.template;
        }.bind(this),
Fatih Acet's avatar
Fatih Acet committed
260 261
        insertTpl: '${atwho-at}${title}',
        callbacks: {
262
          matcher: this.DefaultOptions.matcher,
263
          beforeInsert: this.DefaultOptions.beforeInsert,
264
          filter: this.DefaultOptions.filter,
Yann Gravrand's avatar
Yann Gravrand committed
265
          sorter: this.DefaultOptions.sorter,
Fatih Acet's avatar
Fatih Acet committed
266
          beforeSave: function(merges) {
267 268 269 270 271 272 273 274 275
            if (gl.GfmAutoComplete.isLoading(merges)) return merges;
            var sanitizeLabelTitle;
            sanitizeLabelTitle = function(title) {
              if (/[\w\?&]+\s+[\w\?&]+/g.test(title)) {
                return "\"" + (sanitize(title)) + "\"";
              } else {
                return sanitize(title);
              }
            };
Fatih Acet's avatar
Fatih Acet committed
276 277
            return $.map(merges, function(m) {
              return {
278
                title: sanitize(m.title),
Fatih Acet's avatar
Fatih Acet committed
279 280 281 282 283 284 285
                color: m.color,
                search: "" + m.title
              };
            });
          }
        }
      });
286
      // We don't instantiate the slash commands autocomplete for note and issue/MR edit forms
287
      $input.filter('[data-supports-slash-commands="true"]').atwho({
288 289
        at: '/',
        alias: 'commands',
290
        searchKey: 'search',
291
        skipSpecialCharacterTest: true,
292
        data: this.defaultLoadingData,
293
        displayTpl: function(value) {
294
          if (this.isLoading(value)) return this.Loading.template;
295 296 297 298 299 300 301 302 303 304 305 306
          var tpl = '<li>/${name}';
          if (value.aliases.length > 0) {
            tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>';
          }
          if (value.params.length > 0) {
            tpl += ' <small><%- params.join(" ") %></small>';
          }
          if (value.description !== '') {
            tpl += '<small class="description"><i><%- description %></i></small>';
          }
          tpl += '</li>';
          return _.template(tpl)(value);
307
        }.bind(this),
308
        insertTpl: function(value) {
309
          var tpl = "/${name} ";
310 311 312 313 314 315 316 317 318 319 320 321 322
          var reference_prefix = null;
          if (value.params.length > 0) {
            reference_prefix = value.params[0][0];
            if (/^[@%~]/.test(reference_prefix)) {
              tpl += '<%- reference_prefix %>';
            }
          }
          return _.template(tpl)({ reference_prefix: reference_prefix });
        },
        suffix: '',
        callbacks: {
          sorter: this.DefaultOptions.sorter,
          filter: this.DefaultOptions.filter,
323
          beforeInsert: this.DefaultOptions.beforeInsert,
324
          beforeSave: function(commands) {
325
            if (gl.GfmAutoComplete.isLoading(commands)) return commands;
326 327 328 329 330 331 332 333 334 335 336 337 338 339
            return $.map(commands, function(c) {
              var search = c.name;
              if (c.aliases.length > 0) {
                search = search + " " + c.aliases.join(" ");
              }
              return {
                name: c.name,
                aliases: c.aliases,
                params: c.params,
                description: c.description,
                search: search
              };
            });
          },
340
          matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) {
341
            var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi;
342 343 344 345 346 347 348
            var match = regexp.exec(subtext);
            if (match) {
              return match[1];
            } else {
              return null;
            }
          }
349 350
        }
      });
351
      return;
Fatih Acet's avatar
Fatih Acet committed
352
    },
353 354 355 356 357 358 359 360 361 362
    fetchData: function($input, at) {
      if (this.isLoadingData[at]) return;
      this.isLoadingData[at] = true;
      if (this.cachedData[at]) {
        this.loadData($input, at, this.cachedData[at]);
      } else {
        $.getJSON(this.dataSources[this.atTypeMap[at]], (data) => {
          this.loadData($input, at, data);
        }).fail(() => { this.isLoadingData[at] = false; });
      }
Fatih Acet's avatar
Fatih Acet committed
363
    },
364 365 366 367
    loadData: function($input, at, data) {
      this.isLoadingData[at] = false;
      this.cachedData[at] = data;
      $input.atwho('load', at, data);
368 369
      // This trigger at.js again
      // otherwise we would be stuck with loading until the user types
370 371 372
      return $input.trigger('keyup');
    },
    isLoading(data) {
373 374 375 376 377 378 379 380
      var dataToInspect = data;
      if (data && data.length > 0) {
        dataToInspect = data[0];
      }

      var loadingState = this.defaultLoadingData[0];
      return dataToInspect &&
        (dataToInspect === loadingState || dataToInspect.name === loadingState);
381
    }
Fatih Acet's avatar
Fatih Acet committed
382
  };
383
}).call(window);