Commit 8cf214eb authored by Martin Wortschack's avatar Martin Wortschack

Merge branch 'display-artifacts-dropdown-on-mr-widget' into 'master'

Display Artifacts Dropdown on MR Pipeline Widget

See merge request gitlab-org/gitlab!50998
parents 37fb2ea5 5ed3dac0
<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
...@@ -10200,6 +10200,9 @@ msgstr "" ...@@ -10200,6 +10200,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