Commit 4b529f64 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '33596-add-pipeline-metadata-to-the-package-registry-ui' into 'master'

Resolve "Add GitLab metadata to the Package Registry UI"

See merge request gitlab-org/gitlab!22485
parents f243f539 1fa8b72c
......@@ -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