Commit 5ed3dac0 authored by Andrew Fontaine's avatar Andrew Fontaine Committed by Martin Wortschack

Display Artifacts Dropdown on MR Pipeline Widget

This is much easier to locate than the collapasable artifact MR widget.

Also update the pipeline artifact widget to utlize GitLab UI.
parent 720bd35b
<script> <script>
/* eslint-disable @gitlab/vue-require-i18n-strings */ import { GlDropdown, GlDropdownItem, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { GlLink, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import { __ } from '~/locale';
export default { export default {
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
components: { components: {
GlIcon, GlDropdown,
GlLink, GlDropdownItem,
GlSprintf,
},
translations: {
artifacts: __('Artifacts'),
downloadArtifact: __('Download %{name} artifact'),
}, },
props: { props: {
artifacts: { artifacts: {
...@@ -19,24 +24,25 @@ export default { ...@@ -19,24 +24,25 @@ export default {
}; };
</script> </script>
<template> <template>
<div class="btn-group" role="group"> <gl-dropdown
<button v-gl-tooltip
v-gl-tooltip class="build-artifacts js-pipeline-dropdown-download"
type="button" :title="$options.translations.artifacts"
class="dropdown-toggle build-artifacts btn btn-default js-pipeline-dropdown-download" :text="$options.translations.artifacts"
:title="__('Artifacts')" :aria-label="$options.translations.artifacts"
data-toggle="dropdown" icon="download"
:aria-label="__('Artifacts')" text-sr-only
>
<gl-dropdown-item
v-for="(artifact, i) in artifacts"
:key="i"
:href="artifact.path"
rel="nofollow"
download
> >
<gl-icon name="download" /> <gl-sprintf :message="$options.translations.downloadArtifact">
<gl-icon name="chevron-down" /> <template #name>{{ artifact.name }}</template>
</button> </gl-sprintf>
<ul class="dropdown-menu dropdown-menu-right"> </gl-dropdown-item>
<li v-for="(artifact, i) in artifacts" :key="i"> </gl-dropdown>
<gl-link :href="artifact.path" rel="nofollow" download
>Download {{ artifact.name }} artifact</gl-link
>
</li>
</ul>
</div>
</template> </template>
...@@ -346,7 +346,6 @@ export default { ...@@ -346,7 +346,6 @@ export default {
<pipelines-artifacts-component <pipelines-artifacts-component
v-if="pipeline.details.artifacts.length" v-if="pipeline.details.artifacts.length"
:artifacts="pipeline.details.artifacts" :artifacts="pipeline.details.artifacts"
class="d-md-block"
/> />
<gl-button <gl-button
......
...@@ -11,6 +11,7 @@ import { ...@@ -11,6 +11,7 @@ import {
import mrWidgetPipelineMixin from 'ee_else_ce/vue_merge_request_widget/mixins/mr_widget_pipeline'; import mrWidgetPipelineMixin from 'ee_else_ce/vue_merge_request_widget/mixins/mr_widget_pipeline';
import { s__, n__ } from '~/locale'; import { s__, n__ } from '~/locale';
import PipelineStage from '~/pipelines/components/pipelines_list/stage.vue'; import PipelineStage from '~/pipelines/components/pipelines_list/stage.vue';
import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
...@@ -23,6 +24,7 @@ export default { ...@@ -23,6 +24,7 @@ export default {
GlIcon, GlIcon,
GlSprintf, GlSprintf,
GlTooltip, GlTooltip,
PipelineArtifacts,
PipelineStage, PipelineStage,
TooltipOnTruncate, TooltipOnTruncate,
LinkedPipelinesMiniList: () => LinkedPipelinesMiniList: () =>
...@@ -97,6 +99,9 @@ export default { ...@@ -97,6 +99,9 @@ export default {
hasCommitInfo() { hasCommitInfo() {
return this.pipeline.commit && Object.keys(this.pipeline.commit).length > 0; return this.pipeline.commit && Object.keys(this.pipeline.commit).length > 0;
}, },
hasArtifacts() {
return this.pipeline?.details?.artifacts?.length > 0;
},
isMergeRequestPipeline() { isMergeRequestPipeline() {
return Boolean(this.pipeline.flags && this.pipeline.flags.merge_request_pipeline); return Boolean(this.pipeline.flags && this.pipeline.flags.merge_request_pipeline);
}, },
...@@ -218,7 +223,6 @@ export default { ...@@ -218,7 +223,6 @@ export default {
data-testid="pipeline-coverage-delta" data-testid="pipeline-coverage-delta"
>({{ pipelineCoverageDelta }}%)</span >({{ pipelineCoverageDelta }}%)</span
> >
{{ pipelineCoverageJobNumberText }} {{ pipelineCoverageJobNumberText }}
<span ref="pipelineCoverageQuestion"> <span ref="pipelineCoverageQuestion">
<gl-icon name="question" :size="12" /> <gl-icon name="question" :size="12" />
...@@ -258,6 +262,11 @@ export default { ...@@ -258,6 +262,11 @@ export default {
</template> </template>
</span> </span>
<linked-pipelines-mini-list v-if="triggered.length" :triggered="triggered" /> <linked-pipelines-mini-list v-if="triggered.length" :triggered="triggered" />
<pipeline-artifacts
v-if="hasArtifacts"
:artifacts="pipeline.details.artifacts"
class="gl-ml-3"
/>
</span> </span>
</div> </div>
</div> </div>
......
...@@ -21,6 +21,16 @@ class MergeRequests::PipelineEntity < Grape::Entity ...@@ -21,6 +21,16 @@ class MergeRequests::PipelineEntity < Grape::Entity
pipeline.present.name pipeline.present.name
end end
expose :artifacts do |pipeline, options|
rel = pipeline.downloadable_artifacts
if Feature.enabled?(:non_public_artifacts, type: :development)
rel = rel.select { |artifact| can?(request.current_user, :read_job_artifacts, artifact.job) }
end
BuildArtifactEntity.represent(rel, options)
end
expose :detailed_status, as: :status, with: DetailedStatusEntity do |pipeline| expose :detailed_status, as: :status, with: DetailedStatusEntity do |pipeline|
pipeline.detailed_status(request.current_user) pipeline.detailed_status(request.current_user)
end end
......
---
title: Display Artifacts Dropdown on MR Pipeline Widget
merge_request: 50998
author:
type: added
...@@ -10182,6 +10182,9 @@ msgstr "" ...@@ -10182,6 +10182,9 @@ msgstr ""
msgid "Download %{format}:" msgid "Download %{format}:"
msgstr "" msgstr ""
msgid "Download %{name} artifact"
msgstr ""
msgid "Download CSV" msgid "Download CSV"
msgstr "" msgstr ""
......
import { shallowMount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { GlLink } from '@gitlab/ui'; import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue'; import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue';
describe('Pipelines Artifacts dropdown', () => { describe('Pipelines Artifacts dropdown', () => {
let wrapper; let wrapper;
const createComponent = () => { const createComponent = () => {
wrapper = shallowMount(PipelineArtifacts, { wrapper = mount(PipelineArtifacts, {
propsData: { propsData: {
artifacts: [ artifacts: [
{ {
...@@ -22,8 +22,8 @@ describe('Pipelines Artifacts dropdown', () => { ...@@ -22,8 +22,8 @@ describe('Pipelines Artifacts dropdown', () => {
}); });
}; };
const findGlLink = () => wrapper.find(GlLink); const findFirstGlDropdownItem = () => wrapper.find(GlDropdownItem);
const findAllGlLinks = () => wrapper.find('.dropdown-menu').findAll(GlLink); const findAllGlDropdownItems = () => wrapper.find(GlDropdown).findAll(GlDropdownItem);
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent();
...@@ -35,12 +35,12 @@ describe('Pipelines Artifacts dropdown', () => { ...@@ -35,12 +35,12 @@ describe('Pipelines Artifacts dropdown', () => {
}); });
it('should render a dropdown with all the provided artifacts', () => { it('should render a dropdown with all the provided artifacts', () => {
expect(findAllGlLinks()).toHaveLength(2); expect(findAllGlDropdownItems()).toHaveLength(2);
}); });
it('should render a link with the provided path', () => { it('should render a link with the provided path', () => {
expect(findGlLink().attributes('href')).toEqual('/download/path'); expect(findFirstGlDropdownItem().find('a').attributes('href')).toEqual('/download/path');
expect(findGlLink().text()).toContain('artifact'); expect(findFirstGlDropdownItem().text()).toContain('artifact');
}); });
}); });
...@@ -7,7 +7,7 @@ import { TEST_HOST as FAKE_ENDPOINT } from 'helpers/test_constants'; ...@@ -7,7 +7,7 @@ import { TEST_HOST as FAKE_ENDPOINT } from 'helpers/test_constants';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import ArtifactsListApp from '~/vue_merge_request_widget/components/artifacts_list_app.vue'; import ArtifactsListApp from '~/vue_merge_request_widget/components/artifacts_list_app.vue';
import { getStoreConfig } from '~/vue_merge_request_widget/stores/artifacts_list'; import { getStoreConfig } from '~/vue_merge_request_widget/stores/artifacts_list';
import { artifactsList } from './mock_data'; import { artifacts } from '../mock_data';
Vue.use(Vuex); Vue.use(Vuex);
...@@ -78,9 +78,9 @@ describe('Merge Requests Artifacts list app', () => { ...@@ -78,9 +78,9 @@ describe('Merge Requests Artifacts list app', () => {
describe('with results', () => { describe('with results', () => {
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent();
mock.onGet(FAKE_ENDPOINT).reply(200, artifactsList, {}); mock.onGet(FAKE_ENDPOINT).reply(200, artifacts, {});
store.dispatch('receiveArtifactsSuccess', { store.dispatch('receiveArtifactsSuccess', {
data: artifactsList, data: artifacts,
status: 200, status: 200,
}); });
return nextTick(); return nextTick();
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlLink } from '@gitlab/ui'; import { GlLink } from '@gitlab/ui';
import ArtifactsList from '~/vue_merge_request_widget/components/artifacts_list.vue'; import ArtifactsList from '~/vue_merge_request_widget/components/artifacts_list.vue';
import { artifactsList } from './mock_data'; import { artifacts } from '../mock_data';
describe('Artifacts List', () => { describe('Artifacts List', () => {
let wrapper; let wrapper;
const data = { const data = {
artifacts: artifactsList, artifacts,
}; };
const mountComponent = (props) => { const mountComponent = (props) => {
......
export const artifactsList = [
{
text: 'result.txt',
url: 'bar',
job_name: 'generate-artifact',
job_path: 'bar',
},
{
text: 'foo.txt',
url: 'foo',
job_name: 'foo-artifact',
job_path: 'foo',
},
];
import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/constants'; import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/constants';
export const artifacts = [
{
text: 'result.txt',
url: 'bar',
job_name: 'generate-artifact',
job_path: 'bar',
},
{
text: 'foo.txt',
url: 'foo',
job_name: 'foo-artifact',
job_path: 'foo',
},
];
export default { export default {
id: 132, id: 132,
iid: 22, iid: 22,
...@@ -84,6 +99,7 @@ export default { ...@@ -84,6 +99,7 @@ export default {
coverage: '92.16', coverage: '92.16',
path: '/root/acets-app/pipelines/172', path: '/root/acets-app/pipelines/172',
details: { details: {
artifacts,
status: { status: {
icon: 'status_success', icon: 'status_success',
favicon: 'favicon_status_success', favicon: 'favicon_status_success',
...@@ -127,7 +143,6 @@ export default { ...@@ -127,7 +143,6 @@ export default {
dropdown_path: '/root/acets-app/pipelines/172/stage.json?stage=review', dropdown_path: '/root/acets-app/pipelines/172/stage.json?stage=review',
}, },
], ],
artifacts: [],
manual_actions: [ manual_actions: [
{ {
name: 'stop_review', name: 'stop_review',
...@@ -275,6 +290,7 @@ export const mockStore = { ...@@ -275,6 +290,7 @@ export const mockStore = {
pipeline: { pipeline: {
id: 0, id: 0,
details: { details: {
artifacts,
status: { status: {
details_path: '/root/review-app-tester/pipelines/66', details_path: '/root/review-app-tester/pipelines/66',
favicon: favicon:
...@@ -294,6 +310,7 @@ export const mockStore = { ...@@ -294,6 +310,7 @@ export const mockStore = {
mergePipeline: { mergePipeline: {
id: 1, id: 1,
details: { details: {
artifacts,
status: { status: {
details_path: '/root/review-app-tester/pipelines/66', details_path: '/root/review-app-tester/pipelines/66',
favicon: favicon:
......
import { title } from '~/vue_merge_request_widget/stores/artifacts_list/getters'; import { title } from '~/vue_merge_request_widget/stores/artifacts_list/getters';
import state from '~/vue_merge_request_widget/stores/artifacts_list/state'; import state from '~/vue_merge_request_widget/stores/artifacts_list/state';
import { artifactsList } from '../../components/mock_data'; import { artifacts } from '../../mock_data';
describe('Artifacts Store Getters', () => { describe('Artifacts Store Getters', () => {
let localState; let localState;
...@@ -24,7 +24,7 @@ describe('Artifacts Store Getters', () => { ...@@ -24,7 +24,7 @@ describe('Artifacts Store Getters', () => {
}); });
describe('when it has artifacts', () => { describe('when it has artifacts', () => {
it('returns artifacts message', () => { it('returns artifacts message', () => {
localState.artifacts = artifactsList; localState.artifacts = artifacts;
expect(title(localState)).toBe('View 2 exposed artifacts'); expect(title(localState)).toBe('View 2 exposed artifacts');
}); });
}); });
......
...@@ -30,7 +30,7 @@ RSpec.describe MergeRequests::PipelineEntity do ...@@ -30,7 +30,7 @@ RSpec.describe MergeRequests::PipelineEntity do
) )
expect(subject[:commit]).to include(:short_id, :commit_path) expect(subject[:commit]).to include(:short_id, :commit_path)
expect(subject[:ref]).to include(:branch) expect(subject[:ref]).to include(:branch)
expect(subject[:details]).to include(:name, :status, :stages) expect(subject[:details]).to include(:artifacts, :name, :status, :stages)
expect(subject[:details][:status]).to include(:icon, :favicon, :text, :label, :tooltip) expect(subject[:details][:status]).to include(:icon, :favicon, :text, :label, :tooltip)
expect(subject[:flags]).to include(:merge_request_pipeline) expect(subject[:flags]).to include(:merge_request_pipeline)
end end
...@@ -42,4 +42,6 @@ RSpec.describe MergeRequests::PipelineEntity do ...@@ -42,4 +42,6 @@ RSpec.describe MergeRequests::PipelineEntity do
expect(entity.as_json).not_to include(:coverage) expect(entity.as_json).not_to include(:coverage)
end end
end end
it_behaves_like 'public artifacts'
end end
...@@ -184,25 +184,6 @@ RSpec.describe PipelineDetailsEntity do ...@@ -184,25 +184,6 @@ RSpec.describe PipelineDetailsEntity do
end end
end end
context 'when a pipeline belongs to a public project' do it_behaves_like 'public artifacts'
let(:project) { create(:project, :public) }
let(:pipeline) { create(:ci_empty_pipeline, status: :success, project: project) }
context 'that has artifacts' do
let!(:build) { create(:ci_build, :success, :artifacts, pipeline: pipeline) }
it 'contains information about artifacts' do
expect(subject[:details][:artifacts].length).to eq(1)
end
end
context 'that has non public artifacts' do
let!(:build) { create(:ci_build, :success, :artifacts, :non_public_artifacts, pipeline: pipeline) }
it 'does not contain information about artifacts' do
expect(subject[:details][:artifacts].length).to eq(0)
end
end
end
end end
end end
# frozen_string_literal: true
RSpec.shared_examples 'public artifacts' do
let_it_be(:project) { create(:project, :public) }
let(:pipeline) { create(:ci_empty_pipeline, status: :success, project: project) }
context 'that has artifacts' do
let!(:build) { create(:ci_build, :success, :artifacts, pipeline: pipeline) }
it 'contains information about artifacts' do
expect(subject[:details][:artifacts].length).to eq(1)
end
end
context 'that has non public artifacts' do
let!(:build) { create(:ci_build, :success, :artifacts, :non_public_artifacts, pipeline: pipeline) }
it 'does not contain information about artifacts' do
expect(subject[:details][:artifacts].length).to eq(0)
end
end
end
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