merge_request_tabs.js 13.2 KB
Newer Older
1
/* eslint-disable no-new, class-methods-use-this */
2 3
/* global Breakpoints */
/* global Flash */
4
/* global notes */
5

6
import Vue from 'vue';
7
import Cookies from 'js-cookie';
8 9
import './breakpoints';
import './flash';
10
import BlobForkSuggestion from './blob/blob_fork_suggestion';
11
import commitPipelinesTable from './commit/pipelines/pipelines_table.vue';
12 13

/* eslint-disable max-len */
14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
// MergeRequestTabs
//
// Handles persisting and restoring the current tab selection and lazily-loading
// content on the MergeRequests#show page.
//
// ### Example Markup
//
//   <ul class="nav-links merge-request-tabs">
//     <li class="notes-tab active">
//       <a data-action="notes" data-target="#notes" data-toggle="tab" href="/foo/bar/merge_requests/1">
//         Discussion
//       </a>
//     </li>
//     <li class="commits-tab">
//       <a data-action="commits" data-target="#commits" data-toggle="tab" href="/foo/bar/merge_requests/1/commits">
//         Commits
//       </a>
//     </li>
//     <li class="diffs-tab">
//       <a data-action="diffs" data-target="#diffs" data-toggle="tab" href="/foo/bar/merge_requests/1/diffs">
//         Diffs
//       </a>
//     </li>
//   </ul>
//
//   <div class="tab-content">
//     <div class="notes tab-pane active" id="notes">
//       Notes Content
//     </div>
//     <div class="commits tab-pane" id="commits">
//       Commits Content
//     </div>
//     <div class="diffs tab-pane" id="diffs">
//       Diffs Content
//     </div>
//   </div>
//
//   <div class="mr-loading-status">
//     <div class="loading">
//       Loading Animation
//     </div>
//   </div>
//
57 58
/* eslint-enable max-len */

59
(() => {
60 61
  // Store the `location` object, allowing for easier stubbing in tests
  let location = window.location;
F
Fatih Acet 已提交
62

63
  class MergeRequestTabs {
F
Fatih Acet 已提交
64

65
    constructor({ action, setUrl, stubLocation } = {}) {
66
      this.diffsLoaded = false;
67
      this.pipelinesLoaded = false;
68 69
      this.commitsLoaded = false;
      this.fixedLayoutPref = null;
F
Fatih Acet 已提交
70

71 72 73 74
      this.setUrl = setUrl !== undefined ? setUrl : true;
      this.setCurrentAction = this.setCurrentAction.bind(this);
      this.tabShown = this.tabShown.bind(this);
      this.showTab = this.showTab.bind(this);
F
Fatih Acet 已提交
75

76 77 78 79
      if (stubLocation) {
        location = stubLocation;
      }

F
Fatih Acet 已提交
80
      this.bindEvents();
81
      this.activateTab(action);
82
      this.initAffix();
F
Fatih Acet 已提交
83 84
    }

85
    bindEvents() {
86 87 88
      $(document)
        .on('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown)
        .on('click', '.js-show-tab', this.showTab);
K
Kushal Pandya 已提交
89 90 91

      $('.merge-request-tabs a[data-toggle="tab"]')
        .on('click', this.clickTab);
92
    }
93

A
Alfredo Sumaran 已提交
94
    // Used in tests
95
    unbindEvents() {
96 97 98
      $(document)
        .off('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown)
        .off('click', '.js-show-tab', this.showTab);
K
Kushal Pandya 已提交
99 100 101

      $('.merge-request-tabs a[data-toggle="tab"]')
        .off('click', this.clickTab);
102
    }
F
Fatih Acet 已提交
103

104
    destroyPipelinesView() {
105 106
      if (this.commitPipelinesTable) {
        this.commitPipelinesTable.$destroy();
P
Phil Hughes 已提交
107 108 109
        this.commitPipelinesTable = null;

        document.querySelector('#commit-pipeline-table-view').innerHTML = '';
110 111 112
      }
    }

113 114 115
    showTab(e) {
      e.preventDefault();
      this.activateTab($(e.target).data('action'));
116
    }
F
Fatih Acet 已提交
117

K
Kushal Pandya 已提交
118
    clickTab(e) {
119 120
      if (e.currentTarget && gl.utils.isMetaClick(e)) {
        const targetLink = e.currentTarget.getAttribute('href');
K
Kushal Pandya 已提交
121
        e.stopImmediatePropagation();
122
        e.preventDefault();
K
Kushal Pandya 已提交
123
        window.open(targetLink, '_blank');
K
Kushal Pandya 已提交
124 125 126
      }
    }

127 128
    tabShown(e) {
      const $target = $(e.target);
129 130
      const action = $target.data('action');

F
Fatih Acet 已提交
131 132 133
      if (action === 'commits') {
        this.loadCommits($target.attr('href'));
        this.expandView();
134
        this.resetViewContainer();
135
        this.destroyPipelinesView();
136
      } else if (this.isDiffAction(action)) {
F
Fatih Acet 已提交
137
        this.loadDiff($target.attr('href'));
138
        if (Breakpoints.get().getBreakpointSize() !== 'lg') {
F
Fatih Acet 已提交
139 140
          this.shrinkView();
        }
141 142 143
        if (this.diffViewType() === 'parallel') {
          this.expandViewContainer();
        }
144
        this.destroyPipelinesView();
145
      } else if (action === 'pipelines') {
146
        this.resetViewContainer();
147
        this.mountPipelinesView();
F
Fatih Acet 已提交
148 149
      } else {
        this.expandView();
150
        this.resetViewContainer();
151
        this.destroyPipelinesView();
F
Fatih Acet 已提交
152
      }
153
      if (this.setUrl) {
154 155
        this.setCurrentAction(action);
      }
156
    }
F
Fatih Acet 已提交
157

158
    scrollToElement(container) {
159
      if (location.hash) {
A
Annabel Dunstone Gray 已提交
160
        const offset = -$('.js-tabs-affix').outerHeight();
161
        const $el = $(`${container} ${location.hash}:not(.match)`);
162
        if ($el.length) {
163
          $.scrollTo($el[0], { offset });
F
Fatih Acet 已提交
164 165
        }
      }
166
    }
F
Fatih Acet 已提交
167

168
    // Activate a tab based on the current action
169
    activateTab(action) {
170
      const activate = action === 'show' ? 'notes' : action;
171
      // important note: the .tab('show') method triggers 'shown.bs.tab' event itself
172
      $(`.merge-request-tabs a[data-action='${activate}']`).tab('show');
173
    }
F
Fatih Acet 已提交
174

175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194
    // Replaces the current Merge Request-specific action in the URL with a new one
    //
    // If the action is "notes", the URL is reset to the standard
    // `MergeRequests#show` route.
    //
    // Examples:
    //
    //   location.pathname # => "/namespace/project/merge_requests/1"
    //   setCurrentAction('diffs')
    //   location.pathname # => "/namespace/project/merge_requests/1/diffs"
    //
    //   location.pathname # => "/namespace/project/merge_requests/1/diffs"
    //   setCurrentAction('notes')
    //   location.pathname # => "/namespace/project/merge_requests/1"
    //
    //   location.pathname # => "/namespace/project/merge_requests/1/diffs"
    //   setCurrentAction('commits')
    //   location.pathname # => "/namespace/project/merge_requests/1/commits"
    //
    // Returns the new URL String
195
    setCurrentAction(action) {
196
      this.currentAction = action === 'show' ? 'notes' : action;
197

198 199
      // Remove a trailing '/commits' '/diffs' '/pipelines' '/new' '/new/diffs'
      let newState = location.pathname.replace(/\/(commits|diffs|pipelines|new|new\/diffs)(\.html)?\/?$/, '');
200

201
      // Append the new action if we're on a tab other than 'notes'
202 203
      if (this.currentAction !== 'notes') {
        newState += `/${this.currentAction}`;
F
Fatih Acet 已提交
204
      }
205

206
      // Ensure parameters and hash come along for the ride
207
      newState += location.search + location.hash;
208

B
Bryce Johnson 已提交
209 210
      // TODO: Consider refactoring in light of turbolinks removal.

211 212 213 214
      // Replace the current history state with the new one without breaking
      // Turbolinks' history.
      //
      // See https://github.com/rails/turbolinks/issues/363
215 216 217
      window.history.replaceState({
        url: newState,
      }, document.title, newState);
218

219
      return newState;
220
    }
F
Fatih Acet 已提交
221

222
    loadCommits(source) {
F
Fatih Acet 已提交
223 224 225
      if (this.commitsLoaded) {
        return;
      }
226
      this.ajaxGet({
227
        url: `${source}.json`,
228
        success: (data) => {
229
          document.querySelector('div#commits').innerHTML = data.html;
230 231
          gl.utils.localTimeAgo($('.js-timeago', 'div#commits'));
          this.commitsLoaded = true;
232 233
          this.scrollToElement('#commits');
        },
F
Fatih Acet 已提交
234
      });
235
    }
F
Fatih Acet 已提交
236

237
    mountPipelinesView() {
238 239 240 241 242 243 244 245 246
      const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view');
      const CommitPipelinesTable = Vue.extend(commitPipelinesTable);
      this.commitPipelinesTable = new CommitPipelinesTable({
        propsData: {
          endpoint: pipelineTableViewEl.dataset.endpoint,
          helpPagePath: pipelineTableViewEl.dataset.helpPagePath,
        },
      }).$mount();

247 248
      // $mount(el) replaces the el with the new rendered component. We need it in order to mount
      // it everytime this tab is clicked - https://vuejs.org/v2/api/#vm-mount
249
      pipelineTableViewEl.appendChild(this.commitPipelinesTable.$el);
250 251
    }

252
    loadDiff(source) {
F
Fatih Acet 已提交
253 254 255
      if (this.diffsLoaded) {
        return;
      }
256 257 258

      // We extract pathname for the current Changes tab anchor href
      // some pages like MergeRequestsController#new has query parameters on that anchor
S
Steffen Rauh 已提交
259
      const urlPathname = gl.utils.parseUrlPathname(source);
260

261
      this.ajaxGet({
262
        url: `${urlPathname}.json${location.search}`,
263
        success: (data) => {
264 265
          const $container = $('#diffs');
          $container.html(data.html);
266 267 268 269 270 271 272 273

          if (typeof gl.diffNotesCompileComponents !== 'undefined') {
            gl.diffNotesCompileComponents();
          }

          gl.utils.localTimeAgo($('.js-timeago', 'div#diffs'));
          $('#diffs .js-syntax-highlight').syntaxHighlight();

274
          if (this.diffViewType() === 'parallel' && this.isDiffAction(this.currentAction)) {
275 276 277 278
            this.expandViewContainer();
          }
          this.diffsLoaded = true;

279 280
          new gl.Diff();
          this.scrollToElement('#diffs');
281 282 283 284 285 286 287 288

          $('.diff-file').each((i, el) => {
            new BlobForkSuggestion({
              openButtons: $(el).find('.js-edit-blob-link-fork-toggler'),
              forkButtons: $(el).find('.js-fork-suggestion-button'),
              cancelButtons: $(el).find('.js-cancel-fork-suggestion-button'),
              suggestionSections: $(el).find('.js-file-fork-suggestion-section'),
              actionTextPieces: $(el).find('.js-file-fork-suggestion-section-action'),
289 290
            })
              .init();
291
          });
292 293 294 295 296

          // Scroll any linked note into view
          // Similar to `toggler_behavior` in the discussion tab
          const hash = window.gl.utils.getLocationHash();
          const anchor = hash && $container.find(`[id="${hash}"]`);
297
          if (anchor && anchor.length > 0) {
298 299
            const notesContent = anchor.closest('.notes_content');
            const lineType = notesContent.hasClass('new') ? 'new' : 'old';
300 301 302 303 304
            notes.toggleDiffNote({
              target: anchor,
              lineType,
              forceShow: true,
            });
305 306 307 308 309
            anchor[0].scrollIntoView();
            // We have multiple elements on the page with `#note_xxx`
            // (discussion and diff tabs) and `:target` only applies to the first
            anchor.addClass('target');
          }
310
        },
F
Fatih Acet 已提交
311
      });
312
    }
A
Annabel Dunstone 已提交
313

314 315 316
    // Show or hide the loading spinner
    //
    // status - Boolean, true to show, false to hide
317 318 319
    toggleLoading(status) {
      $('.mr-loading-status .loading').toggle(status);
    }
F
Fatih Acet 已提交
320

321
    ajaxGet(options) {
322
      const defaults = {
323
        beforeSend: () => this.toggleLoading(true),
324
        error: () => new Flash('An error occurred while fetching this tab.', 'alert'),
325
        complete: () => this.toggleLoading(false),
F
Fatih Acet 已提交
326
        dataType: 'json',
327
        type: 'GET',
F
Fatih Acet 已提交
328
      };
329
      $.ajax($.extend({}, defaults, options));
330
    }
F
Fatih Acet 已提交
331

332
    diffViewType() {
F
Fatih Acet 已提交
333
      return $('.inline-parallel-buttons a.active').data('view-type');
334
    }
F
Fatih Acet 已提交
335

336
    isDiffAction(action) {
337
      return action === 'diffs' || action === 'new/diffs';
338
    }
339

340
    expandViewContainer() {
341
      const $wrapper = $('.content-wrapper .container-fluid');
342 343 344 345
      if (this.fixedLayoutPref === null) {
        this.fixedLayoutPref = $wrapper.hasClass('container-limited');
      }
      $wrapper.removeClass('container-limited');
346
    }
347

348
    resetViewContainer() {
349 350 351 352
      if (this.fixedLayoutPref !== null) {
        $('.content-wrapper .container-fluid')
          .toggleClass('container-limited', this.fixedLayoutPref);
      }
353
    }
F
Fatih Acet 已提交
354

355
    shrinkView() {
356
      const $gutterIcon = $('.js-sidebar-toggle i:visible');
357 358

      // Wait until listeners are set
359
      setTimeout(() => {
360
        // Only when sidebar is expanded
F
Fatih Acet 已提交
361
        if ($gutterIcon.is('.fa-angle-double-right')) {
362
          $gutterIcon.closest('a').trigger('click', [true]);
F
Fatih Acet 已提交
363 364
        }
      }, 0);
365
    }
F
Fatih Acet 已提交
366

367 368
    // Expand the issuable sidebar unless the user explicitly collapsed it
    expandView() {
369
      if (Cookies.get('collapsed_gutter') === 'true') {
F
Fatih Acet 已提交
370 371
        return;
      }
372
      const $gutterIcon = $('.js-sidebar-toggle i:visible');
373 374

      // Wait until listeners are set
375
      setTimeout(() => {
376
        // Only when sidebar is collapsed
F
Fatih Acet 已提交
377
        if ($gutterIcon.is('.fa-angle-double-left')) {
378
          $gutterIcon.closest('a').trigger('click', [true]);
F
Fatih Acet 已提交
379 380
        }
      }, 0);
381
    }
F
Fatih Acet 已提交
382

383
    initAffix() {
384
      const $tabs = $('.js-tabs-affix');
385
      const $fixedNav = $('.navbar-gitlab');
P
Phil Hughes 已提交
386

387 388
      // Screen space on small screens is usually very sparse
      // So we dont affix the tabs on these
P
Phil Hughes 已提交
389
      if (Breakpoints.get().getBreakpointSize() === 'xs' || !$tabs.length) return;
390

391 392 393 394 395 396 397
      /**
        If the browser does not support position sticky, it returns the position as static.
        If the browser does support sticky, then we allow the browser to handle it, if not
        then we default back to Bootstraps affix
      **/
      if ($tabs.css('position') !== 'static') return;

398
      const $diffTabs = $('#diff-notes-app');
399 400

      $tabs.off('affix.bs.affix affix-top.bs.affix')
401 402 403
        .affix({
          offset: {
            top: () => (
404
              $diffTabs.offset().top - $tabs.height() - $fixedNav.height()
405 406 407
            ),
          },
        })
408 409
        .on('affix.bs.affix', () => $diffTabs.css({ marginTop: $tabs.height() }))
        .on('affix-top.bs.affix', () => $diffTabs.css({ marginTop: '' }));
410 411 412 413 414

      // Fix bug when reloading the page already scrolling
      if ($tabs.hasClass('affix')) {
        $tabs.trigger('affix.bs.affix');
      }
415 416
    }
  }
F
Fatih Acet 已提交
417

418 419 420
  window.gl = window.gl || {};
  window.gl.MergeRequestTabs = MergeRequestTabs;
})();