Merge branch '24814-pipeline-tabs' into 'master'

Pipelines tabs

## What does this MR do?
Changes the URL when the builds tab is clicked making it possible to be shared.

1. Adds a standard way to handle linked tabs:
    *   This behaviour is already present in the merge requests, commit and user `show` page. 
    *    This MR introduces a reusable way to accomplish this behaviour for pages with static content.

2. Adds test:
    *   For the linked tabs reusable class
    *  For the pipelines tabs

## Why was this MR needed?
To allow having a sharable URL that represented the opened tab

![tabs](/uploads/91e663c12c6e9ac46a17aa3a9489dc72/tabs.gif)

## Does this MR meet the acceptance criteria?
- [x] [Changelog entry](https://docs.gitlab.com/ce/development/changelog.html) added
- [ ] [Documentation created/updated](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/development/doc_styleguide.md)
- [ ] API support added
- Tests
  - [x] Added for this feature/bug
  - [x] All builds are passing
- [x] Conform by the [merge request performance guides](http://docs.gitlab.com/ce/development/merge_request_performance_guidelines.html)
- [x] Conform by the [style guides](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#style-guides)
- [x] Branch has no merge conflicts with `master` (if it does - rebase it please)
- [x] [Squashed related commits together](https://git-scm.com/book/en/Git-Tools-Rewriting-History#Squashing-Commits)

## What are the relevant issue numbers?
Closes #24814

See merge request !7709
This commit is contained in:
Fatih Acet 2016-12-01 21:20:59 +00:00
commit 629624f30f
13 changed files with 393 additions and 73 deletions

View file

@ -135,8 +135,18 @@
new TreeView(); new TreeView();
} }
break; break;
case 'projects:pipelines:builds':
case 'projects:pipelines:show': case 'projects:pipelines:show':
new gl.Pipelines(); const { controllerAction } = document.querySelector('.js-pipeline-container').dataset;
new gl.Pipelines({
initTabs: true,
tabsOptions: {
action: controllerAction,
defaultAction: 'pipelines',
parentEl: '.pipelines-tabs',
},
});
break; break;
case 'groups:activity': case 'groups:activity':
new gl.Activities(); new gl.Activities();

View file

@ -1,9 +1,20 @@
/* global Element */ /* global Element */
/* eslint-disable consistent-return, max-len */ /* eslint-disable consistent-return, max-len, no-empty, no-plusplus, func-names */
Element.prototype.matches = Element.prototype.matches || Element.prototype.msMatchesSelector;
Element.prototype.closest = Element.prototype.closest || function closest(selector, selectedElement = this) { Element.prototype.closest = Element.prototype.closest || function closest(selector, selectedElement = this) {
if (!selectedElement) return; if (!selectedElement) return;
return selectedElement.matches(selector) ? selectedElement : Element.prototype.closest(selector, selectedElement.parentElement); return selectedElement.matches(selector) ? selectedElement : Element.prototype.closest(selector, selectedElement.parentElement);
}; };
Element.prototype.matches = Element.prototype.matches ||
Element.prototype.matchesSelector ||
Element.prototype.mozMatchesSelector ||
Element.prototype.msMatchesSelector ||
Element.prototype.oMatchesSelector ||
Element.prototype.webkitMatchesSelector ||
function (s) {
const matches = (this.document || this.ownerDocument).querySelectorAll(s);
let i = matches.length;
while (--i >= 0 && matches.item(i) !== this) {}
return i > -1;
};

View file

@ -0,0 +1,113 @@
/**
* Linked Tabs
*
* Handles persisting and restores the current tab selection and content.
* Reusable component for static content.
*
* ### Example Markup
*
* <ul class="nav-links tab-links">
* <li class="active">
* <a data-action="tab1" data-target="#tab1" data-toggle="tab" href="/path/tab1">
* Tab 1
* </a>
* </li>
* <li class="groups-tab">
* <a data-action="tab2" data-target="#tab2" data-toggle="tab" href="/path/tab2">
* Tab 2
* </a>
* </li>
*
*
* <div class="tab-content">
* <div class="tab-pane" id="tab1">
* Tab 1 Content
* </div>
* <div class="tab-pane" id="tab2">
* Tab 2 Content
* </div>
* </div>
*
*
* ### How to use
*
* new window.gl.LinkedTabs({
* action: "#{controller.action_name}",
* defaultAction: 'tab1',
* parentEl: '.tab-links'
* });
*/
(() => {
window.gl = window.gl || {};
window.gl.LinkedTabs = class LinkedTabs {
/**
* Binds the events and activates de default tab.
*
* @param {Object} options
*/
constructor(options) {
this.options = options || {};
this.defaultAction = this.options.defaultAction;
this.action = this.options.action || this.defaultAction;
if (this.action === 'show') {
this.action = this.defaultAction;
}
this.currentLocation = window.location;
const tabSelector = `${this.options.parentEl} a[data-toggle="tab"]`;
// since this is a custom event we need jQuery :(
$(document)
.off('shown.bs.tab', tabSelector)
.on('shown.bs.tab', tabSelector, e => this.tabShown(e));
this.activateTab(this.action);
}
/**
* Handles the `shown.bs.tab` event to set the currect url action.
*
* @param {type} evt
* @return {Function}
*/
tabShown(evt) {
const source = evt.target.getAttribute('href');
return this.setCurrentAction(source);
}
/**
* Updates the URL with the path that matched the given action.
*
* @param {String} source
* @return {String}
*/
setCurrentAction(source) {
const copySource = source;
copySource.replace(/\/+$/, '');
const newState = `${copySource}${this.currentLocation.search}${this.currentLocation.hash}`;
history.replaceState({
turbolinks: true,
url: newState,
}, document.title, newState);
return newState;
}
/**
* Given the current action activates the correct tab.
* http://getbootstrap.com/javascript/#tab-show
* Note: Will trigger `shown.bs.tab`
*/
activateTab() {
return $(`${this.options.parentEl} a[data-action='${this.action}']`).tab('show');
}
};
})();

View file

@ -1,8 +1,15 @@
//= require lib/utils/bootstrap_linked_tabs
/* eslint-disable */ /* eslint-disable */
((global) => { ((global) => {
class Pipelines { class Pipelines {
constructor() { constructor(options) {
if (options.initTabs && options.tabsOptions) {
new global.LinkedTabs(options.tabsOptions);
}
this.addMarginToBuildColumns(); this.addMarginToBuildColumns();
} }

View file

@ -1,6 +1,6 @@
class Projects::PipelinesController < Projects::ApplicationController class Projects::PipelinesController < Projects::ApplicationController
before_action :pipeline, except: [:index, :new, :create] before_action :pipeline, except: [:index, :new, :create]
before_action :commit, only: [:show] before_action :commit, only: [:show, :builds]
before_action :authorize_read_pipeline! before_action :authorize_read_pipeline!
before_action :authorize_create_pipeline!, only: [:new, :create] before_action :authorize_create_pipeline!, only: [:new, :create]
before_action :authorize_update_pipeline!, only: [:retry, :cancel] before_action :authorize_update_pipeline!, only: [:retry, :cancel]
@ -32,6 +32,14 @@ class Projects::PipelinesController < Projects::ApplicationController
def show def show
end end
def builds
respond_to do |format|
format.html do
render 'show'
end
end
end
def retry def retry
pipeline.retry_failed(current_user) pipeline.retry_failed(current_user)

View file

@ -1,14 +1,17 @@
.tabs-holder .tabs-holder
%ul.nav-links.no-top.no-bottom %ul.pipelines-tabs.nav-links.no-top.no-bottom
%li.active %li.js-pipeline-tab-link
= link_to "Pipeline", "#js-tab-pipeline", data: { target: '#js-tab-pipeline', action: 'pipeline', toggle: 'tab' }, class: 'pipeline-tab' = link_to namespace_project_pipeline_path(@project.namespace, @project, @pipeline), data: { target: 'div#js-tab-pipeline', action: 'pipelines', toggle: 'tab' }, class: 'pipeline-tab' do
%li Pipeline
= link_to "#js-tab-builds", data: { target: '#js-tab-builds', action: 'build', toggle: 'tab' }, class: 'builds-tab' do %li.js-builds-tab-link
= link_to builds_namespace_project_pipeline_path(@project.namespace, @project, @pipeline), data: {target: 'div#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do
Builds Builds
%span.badge= pipeline.statuses.count %span.badge.js-builds-counter= pipeline.statuses.count
.tab-content .tab-content
#js-tab-pipeline.tab-pane.active #js-tab-pipeline.tab-pane
.build-content.middle-block.pipeline-graph .build-content.middle-block.pipeline-graph
.pipeline-visualization .pipeline-visualization
%ul.stage-column-list %ul.stage-column-list

View file

@ -2,7 +2,7 @@
- page_title "Pipeline" - page_title "Pipeline"
= render "projects/pipelines/head" = render "projects/pipelines/head"
%div{ class: container_class } %div.js-pipeline-container{ class: container_class, data: { controller_action: "#{controller.action_name}" } }
- if @commit - if @commit
= render "projects/pipelines/info" = render "projects/pipelines/info"

View file

@ -0,0 +1,4 @@
---
title: Fix Cicking on tabs on pipeline page should set URL
merge_request: 7709
author:

View file

@ -129,6 +129,7 @@ constraints(ProjectUrlConstrainer.new) do
member do member do
post :cancel post :cancel
post :retry post :retry
get :builds
end end
end end

View file

@ -0,0 +1,154 @@
require 'spec_helper'
describe "Pipelines", feature: true, js: true do
include GitlabRoutingHelper
let(:project) { create(:empty_project) }
let(:user) { create(:user) }
before do
login_as(user)
project.team << [user, :developer]
end
describe 'GET /:project/pipelines/:id' do
let(:project) { create(:project) }
let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) }
before do
@success = create(:ci_build, :success, pipeline: pipeline, stage: 'build', name: 'build')
@failed = create(:ci_build, :failed, pipeline: pipeline, stage: 'test', name: 'test', commands: 'test')
@running = create(:ci_build, :running, pipeline: pipeline, stage: 'deploy', name: 'deploy')
@manual = create(:ci_build, :manual, pipeline: pipeline, stage: 'deploy', name: 'manual build')
@external = create(:generic_commit_status, status: 'success', pipeline: pipeline, name: 'jenkins', stage: 'external')
end
before { visit namespace_project_pipeline_path(project.namespace, project, pipeline) }
it 'shows the pipeline graph' do
expect(page).to have_selector('.pipeline-visualization')
expect(page).to have_content('Build')
expect(page).to have_content('Test')
expect(page).to have_content('Deploy')
expect(page).to have_content('Retry failed')
expect(page).to have_content('Cancel running')
end
it 'shows Pipeline tab pane as active' do
expect(page).to have_css('#js-tab-pipeline.active')
end
context 'page tabs' do
it 'shows Pipeline and Builds tabs with link' do
expect(page).to have_link('Pipeline')
expect(page).to have_link('Builds')
end
it 'shows counter in Builds tab' do
expect(page.find('.js-builds-counter').text).to eq(pipeline.statuses.count.to_s)
end
it 'shows Pipeline tab as active' do
expect(page).to have_css('.js-pipeline-tab-link.active')
end
end
context 'retrying builds' do
it { expect(page).not_to have_content('retried') }
context 'when retrying' do
before { click_on 'Retry failed' }
it { expect(page).not_to have_content('Retry failed') }
end
end
context 'canceling builds' do
it { expect(page).not_to have_selector('.ci-canceled') }
context 'when canceling' do
before { click_on 'Cancel running' }
it { expect(page).not_to have_content('Cancel running') }
end
end
end
describe 'GET /:project/pipelines/:id/builds' do
let(:project) { create(:project) }
let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) }
before do
@success = create(:ci_build, :success, pipeline: pipeline, stage: 'build', name: 'build')
@failed = create(:ci_build, :failed, pipeline: pipeline, stage: 'test', name: 'test', commands: 'test')
@running = create(:ci_build, :running, pipeline: pipeline, stage: 'deploy', name: 'deploy')
@manual = create(:ci_build, :manual, pipeline: pipeline, stage: 'deploy', name: 'manual build')
@external = create(:generic_commit_status, status: 'success', pipeline: pipeline, name: 'jenkins', stage: 'external')
end
before { visit builds_namespace_project_pipeline_path(project.namespace, project, pipeline)}
it 'shows a list of builds' do
expect(page).to have_content('Test')
expect(page).to have_content(@success.id)
expect(page).to have_content('Deploy')
expect(page).to have_content(@failed.id)
expect(page).to have_content(@running.id)
expect(page).to have_content(@external.id)
expect(page).to have_content('Retry failed')
expect(page).to have_content('Cancel running')
expect(page).to have_link('Play')
end
it 'shows Builds tab pane as active' do
expect(page).to have_css('#js-tab-builds.active')
end
context 'page tabs' do
it 'shows Pipeline and Builds tabs with link' do
expect(page).to have_link('Pipeline')
expect(page).to have_link('Builds')
end
it 'shows counter in Builds tab' do
expect(page.find('.js-builds-counter').text).to eq(pipeline.statuses.count.to_s)
end
it 'shows Builds tab as active' do
expect(page).to have_css('li.js-builds-tab-link.active')
end
end
context 'retrying builds' do
it { expect(page).not_to have_content('retried') }
context 'when retrying' do
before { click_on 'Retry failed' }
it { expect(page).not_to have_content('Retry failed') }
it { expect(page).to have_selector('.retried') }
end
end
context 'canceling builds' do
it { expect(page).not_to have_selector('.ci-canceled') }
context 'when canceling' do
before { click_on 'Cancel running' }
it { expect(page).not_to have_content('Cancel running') }
it { expect(page).to have_selector('.ci-canceled') }
end
end
context 'playing manual build' do
before do
within '.pipeline-holder' do
click_link('Play')
end
end
it { expect(@manual.reload).to be_pending }
end
end
end

View file

@ -152,65 +152,6 @@ describe "Pipelines" do
end end
end end
describe 'GET /:project/pipelines/:id' do
let(:project) { create(:project) }
let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) }
before do
@success = create(:ci_build, :success, pipeline: pipeline, stage: 'build', name: 'build')
@failed = create(:ci_build, :failed, pipeline: pipeline, stage: 'test', name: 'test', commands: 'test')
@running = create(:ci_build, :running, pipeline: pipeline, stage: 'deploy', name: 'deploy')
@manual = create(:ci_build, :manual, pipeline: pipeline, stage: 'deploy', name: 'manual build')
@external = create(:generic_commit_status, status: 'success', pipeline: pipeline, name: 'jenkins', stage: 'external')
end
before { visit namespace_project_pipeline_path(project.namespace, project, pipeline) }
it 'shows a list of builds' do
expect(page).to have_content('Test')
expect(page).to have_content(@success.id)
expect(page).to have_content('Deploy')
expect(page).to have_content(@failed.id)
expect(page).to have_content(@running.id)
expect(page).to have_content(@external.id)
expect(page).to have_content('Retry failed')
expect(page).to have_content('Cancel running')
expect(page).to have_link('Play')
end
context 'retrying builds' do
it { expect(page).not_to have_content('retried') }
context 'when retrying' do
before { click_on 'Retry failed' }
it { expect(page).not_to have_content('Retry failed') }
it { expect(page).to have_selector('.retried') }
end
end
context 'canceling builds' do
it { expect(page).not_to have_selector('.ci-canceled') }
context 'when canceling' do
before { click_on 'Cancel running' }
it { expect(page).not_to have_content('Cancel running') }
it { expect(page).to have_selector('.ci-canceled') }
end
end
context 'playing manual build' do
before do
within '.pipeline-holder' do
click_link('Play')
end
end
it { expect(@manual.reload).to be_pending }
end
end
describe 'POST /:project/pipelines' do describe 'POST /:project/pipelines' do
let(:project) { create(:project) } let(:project) { create(:project) }

View file

@ -0,0 +1,55 @@
//= require lib/utils/bootstrap_linked_tabs
(() => {
describe('Linked Tabs', () => {
fixture.preload('linked_tabs');
beforeEach(() => {
fixture.load('linked_tabs');
});
describe('when is initialized', () => {
it('should activate the tab correspondent to the given action', () => {
const linkedTabs = new window.gl.LinkedTabs({ // eslint-disable-line
action: 'tab1',
defaultAction: 'tab1',
parentEl: '.linked-tabs',
});
expect(document.querySelector('#tab1').classList).toContain('active');
});
it('should active the default tab action when the action is show', () => {
const linkedTabs = new window.gl.LinkedTabs({ // eslint-disable-line
action: 'show',
defaultAction: 'tab1',
parentEl: '.linked-tabs',
});
expect(document.querySelector('#tab1').classList).toContain('active');
});
});
describe('on click', () => {
it('should change the url according to the clicked tab', () => {
const historySpy = spyOn(history, 'replaceState').and.callFake(() => {});
const linkedTabs = new window.gl.LinkedTabs({ // eslint-disable-line
action: 'show',
defaultAction: 'tab1',
parentEl: '.linked-tabs',
});
const secondTab = document.querySelector('.linked-tabs li:nth-child(2) a');
const newState = secondTab.getAttribute('href') + linkedTabs.currentLocation.search + linkedTabs.currentLocation.hash;
secondTab.click();
expect(historySpy).toHaveBeenCalledWith({
turbolinks: true,
url: newState,
}, document.title, newState);
});
});
});
})();

View file

@ -0,0 +1,13 @@
%ul.nav.nav-tabs.linked-tabs
%li
%a{ href: 'foo/bar/1', data: { target: 'div#tab1', action: 'tab1', toggle: 'tab' } }
Tab 1
%li
%a{ href: 'foo/bar/1/context', data: { target: 'div#tab2', action: 'tab2', toggle: 'tab' } }
Tab 2
.tab-content
#tab1.tab-pane
Tab 1 Content
#tab2.tab-pane
Tab 2 Content