BigW Consortium Gitlab

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

5
import Cookies from 'js-cookie';
6
import './flash';
7
import BlobForkSuggestion from './blob/blob_fork_suggestion';
8
import initChangesDropdown from './init_changes_dropdown';
9
import bp from './breakpoints';
10 11

/* eslint-disable max-len */
12 13 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
// 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>
//
55 56
/* eslint-enable max-len */

57
(() => {
58 59
  // Store the `location` object, allowing for easier stubbing in tests
  let location = window.location;
Fatih Acet committed
60

61
  class MergeRequestTabs {
Fatih Acet committed
62

63
    constructor({ action, setUrl, stubLocation } = {}) {
64
      this.diffsLoaded = false;
65
      this.pipelinesLoaded = false;
66 67
      this.commitsLoaded = false;
      this.fixedLayoutPref = null;
Fatih Acet committed
68

69 70 71 72
      this.setUrl = setUrl !== undefined ? setUrl : true;
      this.setCurrentAction = this.setCurrentAction.bind(this);
      this.tabShown = this.tabShown.bind(this);
      this.showTab = this.showTab.bind(this);
Fatih Acet committed
73

74 75 76 77
      if (stubLocation) {
        location = stubLocation;
      }

Fatih Acet committed
78
      this.bindEvents();
79
      this.activateTab(action);
80
      this.initAffix();
Fatih Acet committed
81 82
    }

83
    bindEvents() {
84 85 86
      $(document)
        .on('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown)
        .on('click', '.js-show-tab', this.showTab);
87 88 89

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

Alfredo Sumaran committed
92
    // Used in tests
93
    unbindEvents() {
94 95 96
      $(document)
        .off('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown)
        .off('click', '.js-show-tab', this.showTab);
97 98 99

      $('.merge-request-tabs a[data-toggle="tab"]')
        .off('click', this.clickTab);
100
    }
Fatih Acet committed
101

102
    destroyPipelinesView() {
103 104
      if (this.commitPipelinesTable) {
        this.commitPipelinesTable.$destroy();
105 106 107
        this.commitPipelinesTable = null;

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

111 112 113
    showTab(e) {
      e.preventDefault();
      this.activateTab($(e.target).data('action'));
114
    }
Fatih Acet committed
115

116
    clickTab(e) {
117 118
      if (e.currentTarget && gl.utils.isMetaClick(e)) {
        const targetLink = e.currentTarget.getAttribute('href');
Kushal Pandya committed
119
        e.stopImmediatePropagation();
120
        e.preventDefault();
Kushal Pandya committed
121
        window.open(targetLink, '_blank');
122 123 124
      }
    }

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

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

158
    scrollToElement(container) {
159
      if (location.hash) {
160 161 162 163
        const offset = 0 - (
          $('.navbar-gitlab').outerHeight() +
          $('.js-tabs-affix').outerHeight()
        );
164
        const $el = $(`${container} ${location.hash}:not(.match)`);
165
        if ($el.length) {
166
          $.scrollTo($el[0], { offset });
Fatih Acet committed
167 168
        }
      }
169
    }
Fatih Acet committed
170

171
    // Activate a tab based on the current action
172
    activateTab(action) {
173
      // important note: the .tab('show') method triggers 'shown.bs.tab' event itself
174
      $(`.merge-request-tabs a[data-action='${action}']`).tab('show');
175
    }
Fatih Acet committed
176

177 178 179 180 181 182 183 184 185 186 187 188
    // 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"
189
    //   setCurrentAction('show')
190 191 192 193 194 195 196
    //   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
197
    setCurrentAction(action) {
198
      this.currentAction = action;
199

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

203
      // Append the new action if we're on a tab other than 'notes'
204
      if (this.currentAction !== 'show' && this.currentAction !== 'new') {
205
        newState += `/${this.currentAction}`;
Fatih Acet committed
206
      }
207

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

Bryce Johnson committed
211 212
      // TODO: Consider refactoring in light of turbolinks removal.

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

221
      return newState;
222
    }
Fatih Acet committed
223

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

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

249 250
      // $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
251
      pipelineTableViewEl.appendChild(this.commitPipelinesTable.$el);
252 253
    }

254
    loadDiff(source) {
Fatih Acet committed
255 256 257
      if (this.diffsLoaded) {
        return;
      }
258 259 260

      // We extract pathname for the current Changes tab anchor href
      // some pages like MergeRequestsController#new has query parameters on that anchor
Steffen Rauh committed
261
      const urlPathname = gl.utils.parseUrlPathname(source);
262

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

269
          initChangesDropdown();
270

271 272 273 274 275 276 277
          if (typeof gl.diffNotesCompileComponents !== 'undefined') {
            gl.diffNotesCompileComponents();
          }

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

278
          if (this.diffViewType() === 'parallel' && this.isDiffAction(this.currentAction)) {
279 280 281 282
            this.expandViewContainer();
          }
          this.diffsLoaded = true;

283 284
          new gl.Diff();
          this.scrollToElement('#diffs');
285 286 287 288 289 290 291 292

          $('.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'),
293 294
            })
              .init();
295
          });
296 297 298 299

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

319 320 321
    // Show or hide the loading spinner
    //
    // status - Boolean, true to show, false to hide
322 323 324
    toggleLoading(status) {
      $('.mr-loading-status .loading').toggle(status);
    }
Fatih Acet committed
325

326
    ajaxGet(options) {
327
      const defaults = {
328
        beforeSend: () => this.toggleLoading(true),
329
        error: () => new Flash('An error occurred while fetching this tab.', 'alert'),
330
        complete: () => this.toggleLoading(false),
Fatih Acet committed
331
        dataType: 'json',
332
        type: 'GET',
Fatih Acet committed
333
      };
334
      $.ajax($.extend({}, defaults, options));
335
    }
Fatih Acet committed
336

337
    diffViewType() {
Fatih Acet committed
338
      return $('.inline-parallel-buttons a.active').data('view-type');
339
    }
Fatih Acet committed
340

341
    isDiffAction(action) {
342
      return action === 'diffs' || action === 'new/diffs';
343
    }
344

345
    expandViewContainer() {
346
      const $wrapper = $('.content-wrapper .container-fluid');
347 348 349 350
      if (this.fixedLayoutPref === null) {
        this.fixedLayoutPref = $wrapper.hasClass('container-limited');
      }
      $wrapper.removeClass('container-limited');
351
    }
352

353
    resetViewContainer() {
354 355 356 357
      if (this.fixedLayoutPref !== null) {
        $('.content-wrapper .container-fluid')
          .toggleClass('container-limited', this.fixedLayoutPref);
      }
358
    }
Fatih Acet committed
359

360
    shrinkView() {
361
      const $gutterIcon = $('.js-sidebar-toggle i:visible');
362 363

      // Wait until listeners are set
364
      setTimeout(() => {
365
        // Only when sidebar is expanded
Fatih Acet committed
366
        if ($gutterIcon.is('.fa-angle-double-right')) {
367
          $gutterIcon.closest('a').trigger('click', [true]);
Fatih Acet committed
368 369
        }
      }, 0);
370
    }
Fatih Acet committed
371

372 373
    // Expand the issuable sidebar unless the user explicitly collapsed it
    expandView() {
374
      if (Cookies.get('collapsed_gutter') === 'true') {
Fatih Acet committed
375 376
        return;
      }
377
      const $gutterIcon = $('.js-sidebar-toggle i:visible');
378 379

      // Wait until listeners are set
380
      setTimeout(() => {
381
        // Only when sidebar is collapsed
Fatih Acet committed
382
        if ($gutterIcon.is('.fa-angle-double-left')) {
383
          $gutterIcon.closest('a').trigger('click', [true]);
Fatih Acet committed
384 385
        }
      }, 0);
386
    }
Fatih Acet committed
387

388
    initAffix() {
389
      const $tabs = $('.js-tabs-affix');
390
      const $fixedNav = $('.navbar-gitlab');
Phil Hughes committed
391

392 393
      // Screen space on small screens is usually very sparse
      // So we dont affix the tabs on these
394
      if (bp.getBreakpointSize() === 'xs' || !$tabs.length) return;
395

396 397 398 399 400 401 402
      /**
        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;

403
      const $diffTabs = $('#diff-notes-app');
404 405

      $tabs.off('affix.bs.affix affix-top.bs.affix')
406 407 408
        .affix({
          offset: {
            top: () => (
409
              $diffTabs.offset().top - $tabs.height() - $fixedNav.height()
410 411 412
            ),
          },
        })
413 414
        .on('affix.bs.affix', () => $diffTabs.css({ marginTop: $tabs.height() }))
        .on('affix-top.bs.affix', () => $diffTabs.css({ marginTop: '' }));
415 416 417 418 419

      // Fix bug when reloading the page already scrolling
      if ($tabs.hasClass('affix')) {
        $tabs.trigger('affix.bs.affix');
      }
420 421
    }
  }
Fatih Acet committed
422

423 424 425
  window.gl = window.gl || {};
  window.gl.MergeRequestTabs = MergeRequestTabs;
})();