Commit 91bbdc90 authored by Winnie Hellmann's avatar Winnie Hellmann Committed by Phil Hughes

Display GPG status on repository and blob pages

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