Skip to content
Snippets Groups Projects
Commit 91bbdc90 authored by Winnie Hellmann's avatar Winnie Hellmann Committed by Phil Hughes
Browse files

Display GPG status on repository and blob pages

parent 1a959e1b
No related branches found
No related tags found
1 merge request!10495Merge Requests - Assignee
Showing
with 213 additions and 134 deletions
import $ from 'jquery';
import { parseQueryStringIntoObject } from '~/lib/utils/common_utils';
import axios from '~/lib/utils/axios_utils';
import flash from '~/flash';
import createFlash from '~/flash';
import { __ } from '~/locale';
 
export default class GpgBadges {
static fetch() {
const badges = $('.js-loading-gpg-badge');
const tag = $('.js-signature-container');
if (tag.length === 0) {
return Promise.resolve();
}
const badges = $('.js-loading-gpg-badge');
 
badges.html('<i class="fa fa-spinner fa-spin"></i>');
 
const displayError = () => createFlash(__('An error occurred while loading commit signatures'));
const endpoint = tag.data('signaturesPath');
if (!endpoint) {
displayError();
return Promise.reject(new Error('Missing commit signatures endpoint!'));
}
const params = parseQueryStringIntoObject(tag.serialize());
return axios.get(tag.data('signaturesPath'), { params })
.then(({ data }) => {
data.signatures.forEach((signature) => {
badges.filter(`[data-commit-sha="${signature.commit_sha}"]`).replaceWith(signature.html);
});
})
.catch(() => flash(__('An error occurred while loading commits')));
return axios
.get(endpoint, { params })
.then(({ data }) => {
data.signatures.forEach(signature => {
badges.filter(`[data-commit-sha="${signature.commit_sha}"]`).replaceWith(signature.html);
});
})
.catch(displayError);
}
}
Loading
Loading
@@ -2,6 +2,7 @@ import Vue from 'vue';
import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
import BlobViewer from '~/blob/viewer/index';
import initBlob from '~/pages/projects/init_blob';
import GpgBadges from '~/gpg_badges';
 
document.addEventListener('DOMContentLoaded', () => {
new BlobViewer(); // eslint-disable-line no-new
Loading
Loading
@@ -26,4 +27,6 @@ document.addEventListener('DOMContentLoaded', () => {
},
});
}
GpgBadges.fetch();
});
Loading
Loading
@@ -7,6 +7,7 @@ import TreeView from '~/tree';
import BlobViewer from '~/blob/viewer/index';
import Activities from '~/activities';
import { ajaxGet } from '~/lib/utils/common_utils';
import GpgBadges from '~/gpg_badges';
import Star from '../../../star';
import notificationsDropdown from '../../../notifications_dropdown';
 
Loading
Loading
@@ -38,4 +39,6 @@ document.addEventListener('DOMContentLoaded', () => {
$(treeSlider).waitForImages(() => {
ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath);
});
GpgBadges.fetch();
});
Loading
Loading
@@ -2,6 +2,7 @@ import $ from 'jquery';
import Vue from 'vue';
import initBlob from '~/blob_edit/blob_bundle';
import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
import GpgBadges from '~/gpg_badges';
import TreeView from '../../../../tree';
import ShortcutsNavigation from '../../../../shortcuts_navigation';
import BlobViewer from '../../../../blob/viewer';
Loading
Loading
@@ -14,7 +15,8 @@ document.addEventListener('DOMContentLoaded', () => {
new BlobViewer(); // eslint-disable-line no-new
new NewCommitForm($('.js-create-dir-form')); // eslint-disable-line no-new
$('#tree-slider').waitForImages(() =>
ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath));
ajaxGet(document.querySelector('.js-tree-content').dataset.logsPath),
);
 
initBlob();
const commitPipelineStatusEl = document.querySelector('.js-commit-pipeline-status');
Loading
Loading
@@ -36,4 +38,6 @@ document.addEventListener('DOMContentLoaded', () => {
},
});
}
GpgBadges.fetch();
});
<script>
import Visibility from 'visibilityjs';
import ciIcon from '~/vue_shared/components/ci_icon.vue';
import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import Poll from '~/lib/utils/poll';
import Flash from '~/flash';
import { s__, sprintf } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
import CommitPipelineService from '../services/commit_pipeline_service';
import Visibility from 'visibilityjs';
import ciIcon from '~/vue_shared/components/ci_icon.vue';
import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import Poll from '~/lib/utils/poll';
import Flash from '~/flash';
import { s__, sprintf } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
import CommitPipelineService from '../services/commit_pipeline_service';
 
export default {
directives: {
tooltip,
export default {
directives: {
tooltip,
},
components: {
ciIcon,
loadingIcon,
},
props: {
endpoint: {
type: String,
required: true,
},
components: {
ciIcon,
loadingIcon,
},
props: {
endpoint: {
type: String,
required: true,
},
/* This prop can be used to replace some of the `render_commit_status`
/* This prop can be used to replace some of the `render_commit_status`
used across GitLab, this way we could use this vue component and add a
realtime status where it makes sense
realtime: {
Loading
Loading
@@ -29,76 +29,77 @@
required: false,
default: true,
}, */
},
data() {
return {
ciStatus: {},
isLoading: true,
};
},
computed: {
statusTitle() {
return sprintf(s__('Commits|Commit: %{commitText}'), { commitText: this.ciStatus.text });
},
data() {
return {
ciStatus: {},
isLoading: true,
};
},
computed: {
statusTitle() {
return sprintf(s__('Commits|Commit: %{commitText}'), { commitText: this.ciStatus.text });
},
},
mounted() {
this.service = new CommitPipelineService(this.endpoint);
this.initPolling();
},
methods: {
successCallback(res) {
const { pipelines } = res.data;
if (pipelines.length > 0) {
// The pipeline entity always keeps the latest pipeline info on the `details.status`
this.ciStatus = pipelines[0].details.status;
}
this.isLoading = false;
},
mounted() {
this.service = new CommitPipelineService(this.endpoint);
this.initPolling();
errorCallback() {
this.ciStatus = {
text: 'not found',
icon: 'status_notfound',
group: 'notfound',
};
this.isLoading = false;
Flash(s__('Something went wrong on our end'));
},
methods: {
successCallback(res) {
const { pipelines } = res.data;
if (pipelines.length > 0) {
// The pipeline entity always keeps the latest pipeline info on the `details.status`
this.ciStatus = pipelines[0].details.status;
}
this.isLoading = false;
},
errorCallback() {
this.ciStatus = {
text: 'not found',
icon: 'status_notfound',
group: 'notfound',
};
this.isLoading = false;
Flash(s__('Something went wrong on our end'));
},
initPolling() {
this.poll = new Poll({
resource: this.service,
method: 'fetchData',
successCallback: response => this.successCallback(response),
errorCallback: this.errorCallback,
});
initPolling() {
this.poll = new Poll({
resource: this.service,
method: 'fetchData',
successCallback: response => this.successCallback(response),
errorCallback: this.errorCallback,
});
if (!Visibility.hidden()) {
this.isLoading = true;
this.poll.makeRequest();
} else {
this.fetchPipelineCommitData();
}
 
Visibility.change(() => {
if (!Visibility.hidden()) {
this.isLoading = true;
this.poll.makeRequest();
this.poll.restart();
} else {
this.fetchPipelineCommitData();
this.poll.stop();
}
Visibility.change(() => {
if (!Visibility.hidden()) {
this.poll.restart();
} else {
this.poll.stop();
}
});
},
fetchPipelineCommitData() {
this.service.fetchData()
.then(this.successCallback)
.catch(this.errorCallback);
},
});
},
destroy() {
this.poll.stop();
fetchPipelineCommitData() {
this.service
.fetchData()
.then(this.successCallback)
.catch(this.errorCallback);
},
};
},
destroy() {
this.poll.stop();
},
};
</script>
<template>
<div>
<div class="ci-status-link">
<loading-icon
v-if="isLoading"
label="Loading pipeline status"
Loading
Loading
@@ -113,6 +114,7 @@
:title="statusTitle"
:aria-label="statusTitle"
:status="ciStatus"
:size="24"
data-container="body"
/>
</a>
Loading
Loading
Loading
Loading
@@ -13,12 +13,19 @@
* />
*/
import tooltip from '../directives/tooltip';
import Icon from '../components/icon.vue';
 
export default {
name: 'ClipboardButton',
directives: {
tooltip,
},
components: {
Icon,
},
props: {
text: {
type: String,
Loading
Loading
@@ -58,10 +65,6 @@ export default {
type="button"
class="btn"
>
<i
aria-hidden="true"
class="fa fa-clipboard"
>
</i>
<icon name="duplicate" />
</button>
</template>
Loading
Loading
@@ -294,6 +294,10 @@
.btn-clipboard {
border: 0;
padding: 0 5px;
svg {
top: auto;
}
}
 
.input-group-prepend,
Loading
Loading
Loading
Loading
@@ -205,7 +205,7 @@
> .ci-status-link,
> .btn,
> .commit-sha-group {
margin-left: $gl-padding-8;
margin-left: $gl-padding;
}
}
 
Loading
Loading
@@ -235,10 +235,6 @@
fill: $gl-text-color-secondary;
}
 
.fa-clipboard {
color: $gl-text-color-secondary;
}
:first-child {
border-bottom-left-radius: $border-radius-default;
border-top-left-radius: $border-radius-default;
Loading
Loading
Loading
Loading
@@ -51,7 +51,7 @@ module ButtonHelper
}
 
content_tag :button, button_attributes do
concat(icon('clipboard', 'aria-hidden': 'true')) unless hide_button_icon
concat(sprite_icon('duplicate')) unless hide_button_icon
concat(button_text)
end
end
Loading
Loading
Loading
Loading
@@ -56,7 +56,7 @@ module CiStatusHelper
status.humanize
end
 
def ci_icon_for_status(status)
def ci_icon_for_status(status, size: 16)
if detailed_status?(status)
return sprite_icon(status.icon)
end
Loading
Loading
@@ -85,7 +85,7 @@ module CiStatusHelper
'status_canceled'
end
 
sprite_icon(icon_name, size: 16)
sprite_icon(icon_name, size: size)
end
 
def pipeline_status_cache_key(pipeline_status)
Loading
Loading
@@ -111,7 +111,8 @@ module CiStatusHelper
'commit',
commit.status(ref),
path,
tooltip_placement: tooltip_placement)
tooltip_placement: tooltip_placement,
icon_size: 24)
end
 
def render_pipeline_status(pipeline, tooltip_placement: 'left')
Loading
Loading
@@ -125,16 +126,16 @@ module CiStatusHelper
Ci::Runner.instance_type.blank?
end
 
def render_status_with_link(type, status, path = nil, tooltip_placement: 'left', cssclass: '', container: 'body')
def render_status_with_link(type, status, path = nil, tooltip_placement: 'left', cssclass: '', container: 'body', icon_size: 16)
klass = "ci-status-link ci-status-icon-#{status.dasherize} #{cssclass}"
title = "#{type.titleize}: #{ci_label_for_status(status)}"
data = { toggle: 'tooltip', placement: tooltip_placement, container: container }
 
if path
link_to ci_icon_for_status(status), path,
link_to ci_icon_for_status(status, size: icon_size), path,
class: klass, title: title, data: data
else
content_tag :span, ci_icon_for_status(status),
content_tag :span, ci_icon_for_status(status, size: icon_size),
class: klass, title: title, data: data
end
end
Loading
Loading
Loading
Loading
@@ -3,6 +3,8 @@
 
- page_title @blob.path, @ref
 
.js-signature-container{ data: { 'signatures-path': namespace_project_signatures_path } }
%div{ class: container_class }
= render 'projects/last_push'
 
Loading
Loading
Loading
Loading
@@ -8,6 +8,10 @@
 
= render partial: 'flash_messages', locals: { project: @project }
 
- if @project.repository_exists? && !@project.empty_repo?
- signatures_path = namespace_project_signatures_path(project_id: @project.path, id: @project.default_branch)
.js-signature-container{ data: { 'signatures-path': signatures_path } }
%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] }
= render "projects/last_push"
 
Loading
Loading
- @no_container = true
- breadcrumb_title _("Repository")
- @content_class = "limit-container-width" unless fluid_layout
- signatures_path = namespace_project_signatures_path(namespace_id: @project.namespace.path, project_id: @project.path, id: @ref)
 
- page_title @path.presence || _("Files"), @ref
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, project_commits_url(@project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits")
 
.js-signature-container{ data: { 'signatures-path': signatures_path } }
%div{ class: [(container_class), ("limit-container-width" unless fluid_layout)] }
= render 'projects/last_push'
= render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_tree_path(@project, @id)
Loading
Loading
@@ -27,7 +27,7 @@
.card-header
.float-right
%button.js-clipboard-trigger.btn.btn-sm{ title: t('sherlock.copy_to_clipboard'), type: :button }
%i.fa.fa-clipboard
= sprite_icon('duplicate')
%pre.hidden
= @query.formatted_query
%strong
Loading
Loading
@@ -42,7 +42,7 @@
.card-header
.float-right
%button.js-clipboard-trigger.btn.btn-sm{ title: t('sherlock.copy_to_clipboard'), type: :button }
%i.fa.fa-clipboard
= sprite_icon('duplicate')
%pre.hidden
= @query.explain
%strong
Loading
Loading
---
title: Display GPG status on repository and blob pages
merge_request: 20524
author:
type: changed
Loading
Loading
@@ -465,7 +465,7 @@ msgstr ""
msgid "An error occurred while importing project: ${details}"
msgstr ""
 
msgid "An error occurred while loading commits"
msgid "An error occurred while loading commit signatures"
msgstr ""
 
msgid "An error occurred while loading diff"
Loading
Loading
Loading
Loading
@@ -121,6 +121,8 @@ describe ButtonHelper do
end
 
describe 'clipboard_button' do
include IconsHelper
let(:user) { create(:user) }
let(:project) { build_stubbed(:project) }
 
Loading
Loading
@@ -145,7 +147,7 @@ describe ButtonHelper do
expect(element.attr('data-clipboard-text')).to eq(nil)
expect(element.inner_text).to eq("")
 
expect(element).to have_selector('.fa.fa-clipboard')
expect(element.to_html).to include sprite_icon('duplicate')
end
end
 
Loading
Loading
@@ -178,7 +180,7 @@ describe ButtonHelper do
 
context 'with `hide_button_icon` attribute provided' do
it 'shows copy to clipboard button without tooltip support' do
expect(element(hide_button_icon: true)).not_to have_selector('.fa.fa-clipboard')
expect(element(hide_button_icon: true).to_html).not_to include sprite_icon('duplicate')
end
end
end
Loading
Loading
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import GpgBadges from '~/gpg_badges';
import { TEST_HOST } from 'spec/test_constants';
 
describe('GpgBadges', () => {
let mock;
const dummyCommitSha = 'n0m0rec0ffee';
const dummyBadgeHtml = 'dummy html';
const dummyResponse = {
signatures: [{
commit_sha: dummyCommitSha,
html: dummyBadgeHtml,
}],
signatures: [
{
commit_sha: dummyCommitSha,
html: dummyBadgeHtml,
},
],
};
const dummyUrl = `${TEST_HOST}/dummy/signatures`;
 
beforeEach(() => {
mock = new MockAdapter(axios);
setFixtures(`
<form
class="commits-search-form js-signature-container" data-signatures-path="/hello" action="/hello"
class="commits-search-form js-signature-container" data-signatures-path="${dummyUrl}" action="${dummyUrl}"
method="get">
<input name="utf8" type="hidden" value="">
<input type="search" name="search" id="commits-search"class="form-control search-text-input input-short">
Loading
Loading
@@ -32,25 +36,55 @@ describe('GpgBadges', () => {
mock.restore();
});
 
it('displays a loading spinner', (done) => {
mock.onGet('/hello').reply(200);
it('does not make a request if there is no container element', done => {
setFixtures('');
spyOn(axios, 'get');
 
GpgBadges.fetch().then(() => {
expect(document.querySelector('.js-loading-gpg-badge:empty')).toBe(null);
const spinners = document.querySelectorAll('.js-loading-gpg-badge i.fa.fa-spinner.fa-spin');
expect(spinners.length).toBe(1);
done();
}).catch(done.fail);
GpgBadges.fetch()
.then(() => {
expect(axios.get).not.toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
 
it('replaces the loading spinner', (done) => {
mock.onGet('/hello').reply(200, dummyResponse);
it('throws an error if the endpoint is missing', done => {
setFixtures('<div class="js-signature-container"></div>');
spyOn(axios, 'get');
 
GpgBadges.fetch().then(() => {
expect(document.querySelector('.js-loading-gpg-badge')).toBe(null);
const parentContainer = document.querySelector('.parent-container');
expect(parentContainer.innerHTML.trim()).toEqual(dummyBadgeHtml);
done();
}).catch(done.fail);
GpgBadges.fetch()
.then(() => done.fail('Expected error to be thrown'))
.catch(error => {
expect(error.message).toBe('Missing commit signatures endpoint!');
expect(axios.get).not.toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
it('displays a loading spinner', done => {
mock.onGet(dummyUrl).replyOnce(200);
GpgBadges.fetch()
.then(() => {
expect(document.querySelector('.js-loading-gpg-badge:empty')).toBe(null);
const spinners = document.querySelectorAll('.js-loading-gpg-badge i.fa.fa-spinner.fa-spin');
expect(spinners.length).toBe(1);
done();
})
.catch(done.fail);
});
it('replaces the loading spinner', done => {
mock.onGet(dummyUrl).replyOnce(200, dummyResponse);
GpgBadges.fetch()
.then(() => {
expect(document.querySelector('.js-loading-gpg-badge')).toBe(null);
const parentContainer = document.querySelector('.parent-container');
expect(parentContainer.innerHTML.trim()).toEqual(dummyBadgeHtml);
done();
})
.catch(done.fail);
});
});
Loading
Loading
@@ -21,7 +21,7 @@ describe('clipboard button', () => {
it('renders a button for clipboard', () => {
expect(vm.$el.tagName).toEqual('BUTTON');
expect(vm.$el.getAttribute('data-clipboard-text')).toEqual('copy me');
expect(vm.$el.querySelector('i').className).toEqual('fa fa-clipboard');
expect(vm.$el).toHaveSpriteIcon('duplicate');
});
 
it('should have a tooltip with default values', () => {
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment