Commit 1fa8b72c authored by Nick Kipling's avatar Nick Kipling Committed by Kushal Pandya

Added pipeline info to package details

Added api call to get pipeline info
Display pipeline info on package details
Updated components to display
Added new Vuex store
Updated pot file
parent f243f539
......@@ -44,6 +44,7 @@ const Api = {
releasePath: '/api/:version/projects/:id/releases/:tag_name',
mergeRequestsPipeline: '/api/:version/projects/:id/merge_requests/:merge_request_iid/pipelines',
adminStatisticsPath: '/api/:version/application/statistics',
pipelineSinglePath: '/api/:version/projects/:id/pipelines/:pipeline_id',
group(groupId, callback) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
......@@ -448,6 +449,14 @@ const Api = {
return axios.get(url);
},
pipelineSingle(id, pipelineId) {
const url = Api.buildUrl(this.pipelineSinglePath)
.replace(':id', encodeURIComponent(id))
.replace(':pipeline_id', encodeURIComponent(pipelineId));
return axios.get(url);
},
buildUrl(url) {
return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version));
},
......
---
title: Packages published to the package registry via CI/CD with a CI_JOB_TOKEN will
display pipeline information on the details page
merge_request: 22485
author:
type: added
......@@ -22,6 +22,7 @@ import { generatePackageInfo } from '../utils';
import { __, s__, sprintf } from '~/locale';
import { PackageType, TrackingActions } from '../../shared/constants';
import { packageTypeToTrackCategory } from '../../shared/utils';
import { mapState } from 'vuex';
export default {
name: 'PackagesApp',
......@@ -45,15 +46,6 @@ export default {
mixins: [timeagoMixin, Tracking.mixin()],
trackingActions: { ...TrackingActions },
props: {
packageEntity: {
type: Object,
required: true,
},
files: {
type: Array,
default: () => [],
required: true,
},
canDelete: {
type: Boolean,
default: false,
......@@ -94,6 +86,7 @@ export default {
},
},
computed: {
...mapState(['packageEntity', 'packageFiles']),
isNpmPackage() {
return this.packageEntity.package_type === PackageType.NPM;
},
......@@ -159,7 +152,7 @@ export default {
}
},
filesTableRows() {
return this.files.map(x => ({
return this.packageFiles.map(x => ({
name: x.file_name,
downloadPath: x.download_path,
size: this.formatSize(x.size),
......
<script>
import { s__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { mapGetters, mapState } from 'vuex';
import { GlIcon, GlLoadingIcon } from '@gitlab/ui';
export default {
name: 'PackageInformation',
components: {
ClipboardButton,
GlIcon,
GlLoadingIcon,
},
props: {
heading: {
......@@ -24,6 +28,17 @@ export default {
default: false,
},
},
computed: {
...mapState(['isLoading', 'pipelineError', 'pipelineInfo']),
...mapGetters(['packageHasPipeline']),
pipelineSha() {
if (this.pipelineInfo?.sha) {
return this.pipelineInfo.sha.substring(0, 7);
}
return '';
},
},
};
</script>
......@@ -46,6 +61,30 @@ export default {
/>
</div>
</li>
<li v-if="packageHasPipeline" class="js-package-pipeline">
<span class="text-secondary">{{ __('Pipeline') }}</span>
<div class="pull-right">
<gl-loading-icon v-if="isLoading" class="vertical-align-middle" size="sm" />
<span v-else-if="pipelineError" class="js-pipeline-error">{{ pipelineError }}</span>
<span v-else class="js-pipeline-info">
<a :href="pipelineInfo.web_url" class="append-right-8">#{{ pipelineInfo.id }}</a>
<gl-icon name="branch" class="append-right-4 vertical-align-middle text-secondary" />
<a :href="`../../tree/${pipelineInfo.ref}`" class="append-right-8">{{
pipelineInfo.ref
}}</a>
<gl-icon name="commit" class="append-right-4 vertical-align-middle text-secondary" /><a
:href="`../../commit/${pipelineInfo.sha}`"
>{{ pipelineSha }}</a
>
<clipboard-button
v-if="pipelineSha"
:text="pipelineInfo.sha"
:title="__('Copy commit SHA')"
css-class="border-0 text-secondary py-0"
/>
</span>
</div>
</li>
</ul>
</div>
</template>
import Vue from 'vue';
import PackagesApp from './components/app.vue';
import Translate from '~/vue_shared/translate';
import createStore from './store';
Vue.use(Translate);
export default () =>
export default () => {
const { dataset } = document.querySelector('#js-vue-packages-detail');
const packageEntity = JSON.parse(dataset.package);
const packageFiles = JSON.parse(dataset.packageFiles);
const canDelete = dataset.canDelete === 'true';
const store = createStore({ packageEntity, packageFiles });
store.dispatch('fetchPipelineInfo');
// eslint-disable-next-line no-new
new Vue({
el: '#js-vue-packages-detail',
components: {
PackagesApp,
},
store,
data() {
const { dataset } = document.querySelector(this.$options.el);
const packageData = JSON.parse(dataset.package);
const packageFiles = JSON.parse(dataset.packageFiles);
const canDelete = dataset.canDelete === 'true';
return {
packageData,
packageFiles,
canDelete,
destroyPath: dataset.destroyPath,
emptySvgPath: dataset.svgPath,
......@@ -33,8 +37,6 @@ export default () =>
render(createElement) {
return createElement('packages-app', {
props: {
packageEntity: this.packageData,
files: this.packageFiles,
canDelete: this.canDelete,
destroyPath: this.destroyPath,
emptySvgPath: this.emptySvgPath,
......@@ -48,3 +50,4 @@ export default () =>
});
},
});
};
import Api from '~/api';
import * as types from './mutation_types';
import createFlash from '~/flash';
import { s__ } from '~/locale';
export const toggleLoading = ({ commit }) => commit(types.TOGGLE_LOADING);
export const fetchPipelineInfo = ({ state, commit, dispatch }) => {
const {
project_id: projectId,
build_info: { pipeline_id: pipelineId } = {},
} = state.packageEntity;
if (projectId && pipelineId) {
dispatch('toggleLoading');
Api.pipelineSingle(projectId, pipelineId)
.then(response => {
const { data } = response;
commit(types.SET_PIPELINE_ERROR, null);
commit(types.SET_PIPELINE_INFO, data);
})
.catch(() => {
createFlash(s__('PackageRegistry|There was an error fetching the pipeline information.'));
commit(
types.SET_PIPELINE_ERROR,
s__('PackageRegistry|Unable to fetch pipeline information'),
);
})
.finally(() => {
dispatch('toggleLoading');
});
}
};
export default ({ packageEntity }) => {
// eslint-disable-next-line camelcase
if (packageEntity?.build_info?.pipeline_id) {
return true;
}
return false;
};
import Vue from 'vue';
import Vuex from 'vuex';
import state from './state';
import * as actions from './actions';
import packageHasPipeline from './getters';
import mutations from './mutations';
Vue.use(Vuex);
export default (initialState = {}) =>
new Vuex.Store({
actions,
getters: {
packageHasPipeline,
},
mutations,
state: {
...state(),
...initialState,
},
});
export const TOGGLE_LOADING = 'TOGGLE_LOADING';
export const SET_PIPELINE_ERROR = 'SET_PIPELINE_ERROR';
export const SET_PIPELINE_INFO = 'SET_PIPELINE_INFO';
import * as types from './mutation_types';
export default {
[types.TOGGLE_LOADING](state) {
Object.assign(state, { isLoading: !state.isLoading });
},
[types.SET_PIPELINE_ERROR](state, pipelineError) {
Object.assign(state, { pipelineError });
},
[types.SET_PIPELINE_INFO](state, pipelineInfo) {
Object.assign(state, { pipelineInfo });
},
};
export default () => ({
packageEntity: null,
packageFiles: [],
pipelineInfo: {},
pipelineError: null,
isLoading: false,
});
......@@ -5,7 +5,7 @@
.row
.col-12
#js-vue-packages-detail{ data: { package: @package.to_json(include: [:conan_metadatum, :maven_metadatum, :package_files, :tags]),
#js-vue-packages-detail{ data: { package: @package.to_json(include: [:conan_metadatum, :maven_metadatum, :package_files, :build_info, :tags]),
package_files: @package_files.to_json(methods: :download_path),
can_delete: can?(current_user, :destroy_package, @project).to_s,
destroy_path: project_package_path(@project, @package),
......
import { mount } from '@vue/test-utils';
import Vuex from 'vuex';
import { mount, createLocalVue } from '@vue/test-utils';
import { GlModal } from '@gitlab/ui';
import Tracking from '~/tracking';
import PackagesApp from 'ee/packages/details/components/app.vue';
......@@ -11,12 +12,14 @@ import { TrackingActions } from 'ee/packages/shared/constants';
import ConanInstallation from 'ee/packages/details/components/conan_installation.vue';
import { conanPackage, mavenPackage, mavenFiles, npmPackage, npmFiles } from '../../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('PackagesApp', () => {
let wrapper;
let store;
const defaultProps = {
packageEntity: mavenPackage,
files: mavenFiles,
canDelete: true,
destroyPath: 'destroy-package-path',
emptySvgPath: 'empty-illustration',
......@@ -28,14 +31,28 @@ describe('PackagesApp', () => {
conanHelpPath: 'foo',
};
function createComponent(props = {}) {
function createComponent(packageEntity = mavenPackage, packageFiles = mavenFiles) {
const propsData = {
...defaultProps,
...props,
};
store = new Vuex.Store({
state: {
isLoading: false,
packageEntity,
packageFiles,
pipelineInfo: {},
pipelineError: null,
},
getters: {
packageHasPipeline: () => packageEntity.build_info && packageEntity.build_info.pipeline_id,
},
});
wrapper = mount(PackagesApp, {
localVue,
propsData,
store,
});
}
......@@ -86,20 +103,14 @@ describe('PackagesApp', () => {
});
it('does not render package metadata for npm as npm packages do not contain metadata', () => {
createComponent({
packageEntity: npmPackage,
files: npmFiles,
});
createComponent(npmPackage, npmFiles);
expect(packageInformation(0)).toExist();
expect(allPackageInformation().length).toBe(1);
});
it('renders package installation instructions for npm packages', () => {
createComponent({
packageEntity: npmPackage,
files: npmFiles,
});
createComponent(npmPackage, npmFiles);
expect(npmInstallation()).toExist();
});
......@@ -111,10 +122,7 @@ describe('PackagesApp', () => {
});
it('renders a single file for an npm package as they only contain one file', () => {
createComponent({
packageEntity: npmPackage,
files: npmFiles,
});
createComponent(npmPackage, npmFiles);
expect(allFileRows()).toExist();
expect(allFileRows().length).toBe(1);
......@@ -148,10 +156,8 @@ describe('PackagesApp', () => {
describe('package tags', () => {
it('displays the package-tags component when the package has tags', () => {
createComponent({
packageEntity: {
...npmPackage,
tags: [{ name: 'foo' }],
},
...npmPackage,
tags: [{ name: 'foo' }],
});
expect(packageTags().exists()).toBe(true);
......@@ -175,13 +181,13 @@ describe('PackagesApp', () => {
});
it('tracking category calls packageTypeToTrackCategory', () => {
createComponent({ packageEntity: conanPackage });
createComponent(conanPackage);
expect(wrapper.vm.tracking.category).toBe(category);
expect(utilSpy).toHaveBeenCalledWith('conan');
});
it(`delete button on delete modal call event with ${TrackingActions.DELETE_PACKAGE}`, () => {
createComponent({ packageEntity: conanPackage, canDelete: true, destroyPath: 'foo' });
createComponent(conanPackage);
deleteButton().trigger('click');
return wrapper.vm.$nextTick().then(() => {
modalDeleteButton().trigger('click');
......@@ -194,7 +200,7 @@ describe('PackagesApp', () => {
});
it(`file download link call event with ${TrackingActions.PULL_PACKAGE}`, () => {
createComponent({ packageEntity: conanPackage });
createComponent(conanPackage);
firstFileDownloadLink().trigger('click');
expect(eventSpy).toHaveBeenCalledWith(
category,
......
import { shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui';
import PackageInformation from 'ee/packages/details/components/information.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { npmPackage, mavenPackage as packageWithoutBuildInfo } from '../../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('PackageInformation', () => {
let wrapper;
let store;
const defaultProps = {
information: [
......@@ -22,14 +29,34 @@ describe('PackageInformation', () => {
],
};
function createComponent(props = {}) {
function createComponent(
props = {},
packageEntity = packageWithoutBuildInfo,
hasPipeline = false,
isLoading = false,
pipelineError = null,
) {
const propsData = {
...defaultProps,
...props,
};
store = new Vuex.Store({
state: {
isLoading,
packageEntity,
pipelineInfo: {},
pipelineError,
},
getters: {
packageHasPipeline: () => hasPipeline,
},
});
wrapper = shallowMount(PackageInformation, {
localVue,
propsData,
store,
});
}
......@@ -40,6 +67,10 @@ describe('PackageInformation', () => {
informationSelector()
.at(index)
.text();
const packagePipelineInfoListItem = () => wrapper.find('.js-package-pipeline');
const pipelineLoader = () => wrapper.find(GlLoadingIcon);
const pipelineErrorMessage = () => wrapper.find('.js-pipeline-error');
const pipelineInfoContent = () => wrapper.find('.js-pipeline-info');
afterEach(() => {
if (wrapper) wrapper.destroy();
......@@ -88,4 +119,36 @@ describe('PackageInformation', () => {
expect(copyButton().at(2).vm.text).toBe(defaultProps.information[2].value);
});
});
describe('pipeline information', () => {
it('does not display pipeline information when no build info is available', () => {
createComponent();
expect(packagePipelineInfoListItem().exists()).toBe(false);
});
it('displays the loading spinner when fetching information', () => {
createComponent({}, npmPackage, true, true);
expect(packagePipelineInfoListItem().exists()).toBe(true);
expect(pipelineLoader().exists()).toBe(true);
});
it('displays that the pipeline error information fetching fails', () => {
const pipelineError = 'an-error-message';
createComponent({}, npmPackage, true, false, pipelineError);
expect(packagePipelineInfoListItem().exists()).toBe(true);
expect(pipelineLoader().exists()).toBe(false);
expect(pipelineErrorMessage().exists()).toBe(true);
expect(pipelineErrorMessage().text()).toBe(pipelineError);
});
it('displays the pipeline information if found', () => {
createComponent({}, npmPackage, true);
expect(packagePipelineInfoListItem().exists()).toBe(true);
expect(pipelineInfoContent().exists()).toBe(true);
});
});
});
import Api from '~/api';
import * as actions from 'ee/packages/details/store/actions';
import * as types from 'ee/packages/details/store/mutation_types';
import testAction from 'helpers/vuex_action_helper';
import createFlash from '~/flash';
import { mockPipelineInfo, npmPackage } from '../../mock_data';
jest.mock('~/api.js');
jest.mock('~/flash.js');
describe('Actions PackageDetails Store', () => {
let state;
const defaultState = {
packageEntity: npmPackage,
};
beforeEach(() => {
state = defaultState;
});
describe('fetch pipeline info', () => {
it('sets pipelineError to null and pipelineInfo to the returned data', done => {
Api.pipelineSingle = jest.fn().mockResolvedValue({ data: mockPipelineInfo });
testAction(
actions.fetchPipelineInfo,
null,
state,
[
{ type: types.SET_PIPELINE_ERROR, payload: null },
{ type: types.SET_PIPELINE_INFO, payload: mockPipelineInfo },
],
[{ type: 'toggleLoading' }, { type: 'toggleLoading' }],
done,
);
});
it('should create flash on API error and set pipelineError', done => {
Api.pipelineSingle = jest.fn().mockRejectedValue();
testAction(
actions.fetchPipelineInfo,
null,
state,
[
{
type: types.SET_PIPELINE_ERROR,
payload: 'Unable to fetch pipeline information',
},
],
[{ type: 'toggleLoading' }, { type: 'toggleLoading' }],
() => {
expect(createFlash).toHaveBeenCalled();
done();
},
);
});
});
describe('toggles loading', () => {
it('sets isLoading to true', done => {
testAction(actions.toggleLoading, {}, state, [{ type: types.TOGGLE_LOADING }], [], done);
});
it('toggles isLoading to false', done => {
testAction(
actions.toggleLoading,
{},
{ ...state, isLoading: true },
[{ type: types.TOGGLE_LOADING }],
[],
done,
);
});
});
});
import packageHasPipeline from 'ee/packages/details/store/getters';
import {
npmPackage,
mavenPackage as packageWithoutBuildInfo,
mockPipelineInfo,
} from '../../mock_data';
describe('Getters PackageDetails Store', () => {
let state;
const mockPipelineError = 'mock-pipeline-error';
const defaultState = {
packageEntity: packageWithoutBuildInfo,
pipelineInfo: mockPipelineInfo,
pipelineError: mockPipelineError,
};
const setupState = (testState = defaultState) => {
state = testState;
};
describe('packageHasPipeline', () => {
it('should return true when build_info and pipeline_id exist', () => {
setupState({
packageEntity: npmPackage,
});
expect(packageHasPipeline(state)).toEqual(true);
});
it('should return false when build_info does not exist', () => {
setupState();
expect(packageHasPipeline(state)).toEqual(false);
});
});
});
import * as types from 'ee/packages/details/store/mutation_types';
import mutations from 'ee/packages/details/store/mutations';
import { mockPipelineInfo as pipelineInfo } from '../../mock_data';
describe('Mutations PackageDetails Store', () => {
let mockState;
const defaultState = {
packageEntity: null,
packageFiles: [],
pipelineInfo: {},
pipelineError: null,
isLoading: false,
};
beforeEach(() => {
mockState = defaultState;
});
describe('set package info', () => {
it('should set packageInfo', () => {
const expectedState = { ...mockState, pipelineInfo };
mutations[types.SET_PIPELINE_INFO](mockState, pipelineInfo);
expect(mockState.pipelineInfo).toEqual(expectedState.pipelineInfo);
});
});
describe('set pipeline error', () => {
it('should set pipelineError', () => {
const pipelineError = 'a-pipeline-error-message';
const expectedState = { ...mockState, pipelineError };
mutations[types.SET_PIPELINE_ERROR](mockState, pipelineError);
expect(mockState.pipelineError).toEqual(expectedState.pipelineError);
});
});
describe('toggle loading', () => {
it('should set to true', () => {
const expectedState = Object.assign({}, mockState, { isLoading: true });
mutations[types.TOGGLE_LOADING](mockState);
expect(mockState.isLoading).toEqual(expectedState.isLoading);
});
it('should toggle back to false', () => {
const expectedState = Object.assign({}, mockState, { isLoading: false });
mockState.isLoading = true;
mutations[types.TOGGLE_LOADING](mockState);
expect(mockState.isLoading).toEqual(expectedState.isLoading);
});
});
});
......@@ -45,6 +45,9 @@ export const npmPackage = {
updated_at: '2015-12-10',
version: '',
_links,
build_info: {
pipeline_id: 1,
},
};
export const npmFiles = [
......@@ -90,3 +93,10 @@ export const mockTags = [
name: 'foo-4',
},
];
export const mockPipelineInfo = {
id: 1,
ref: 'branch-name',
sha: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
web_url: 'foo',
};
......@@ -13026,6 +13026,12 @@ msgstr ""
msgid "PackageRegistry|There was a problem fetching the details for this package."
msgstr ""
msgid "PackageRegistry|There was an error fetching the pipeline information."
msgstr ""
msgid "PackageRegistry|Unable to fetch pipeline information"
msgstr ""
msgid "PackageRegistry|Unable to load package"
msgstr ""
......
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