Commit 804b1c44 authored by Nick Kipling's avatar Nick Kipling

Adds new activity panel to package details

Created new activity component
Removed pipeline info from information
Created new package_presenter for package details
Refactored package data passed to Vue
Removed API request for pipeline info
Removed all Vuex actions for API request
Updated tests to support removal of actions
Updated information tests
Created activity tests
Updated pot
parent 9d12e062
---
title: Adds new activity panel to package details page
merge_request: 25534
author:
type: added
<script>
import { GlAvatar, GlTooltipDirective, GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
import { mapGetters, mapState } from 'vuex';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { __, s__ } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { formatDate } from '~/lib/utils/datetime_utility';
export default {
name: 'PackageActivity',
components: {
ClipboardButton,
GlAvatar,
GlIcon,
GlLink,
GlSprintf,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [timeagoMixin],
data() {
return {
showDescription: false,
};
},
computed: {
...mapState(['packageEntity']),
...mapGetters(['packagePipeline']),
publishedDate() {
return formatDate(this.packageEntity.created_at, 'HH:MM yyyy-mm-dd');
},
},
methods: {
toggleShowDescription() {
this.showDescription = !this.showDescription;
},
},
i18n: {
showCommit: __('Show commit description'),
pipelineText: s__(
'PackageRegistry|Pipeline %{linkStart}%{linkEnd} triggered %{timestamp} by %{author}',
),
publishText: s__('PackageRegistry|Published to the repository at %{timestamp}'),
},
};
</script>
<template>
<div class="append-bottom-default">
<h3 class="gl-font-size-16">{{ __('Activity') }}</h3>
<div ref="commit-info" class="info-well">
<div v-if="packagePipeline" class="well-segment">
<div class="d-flex align-items-center">
<gl-icon name="commit" class="d-none d-sm-block" />
<button
v-if="packagePipeline.git_commit_message"
ref="commit-message-toggle"
v-gl-tooltip
:title="$options.i18n.showCommit"
:aria-label="$options.i18n.showCommit"
class="text-expander append-right-5 d-none d-sm-flex"
@click="toggleShowDescription"
>
<gl-icon name="ellipsis_h" :size="12" />
</button>
<gl-link :href="`../../commit/${packagePipeline.sha}`">{{ packagePipeline.sha }}</gl-link>
<clipboard-button
:text="packagePipeline.sha"
:title="__('Copy commit SHA')"
css-class="border-0 text-secondary py-0"
/>
</div>
<div v-if="showDescription" ref="commit-message" class="prepend-top-8 d-none d-sm-block">
<pre class="append-bottom-0 border-0 p-0">{{ packagePipeline.git_commit_message }}</pre>
</div>
</div>
<div v-if="packagePipeline" ref="pipeline-info" class="well-segment">
<div class="d-flex align-items-center">
<gl-icon name="pipeline" class="append-right-8 d-none d-sm-block" />
<gl-sprintf :message="$options.i18n.pipelineText">
<template #link>
&nbsp;
<gl-link :href="`../../pipelines/${packagePipeline.id}`"
>#{{ packagePipeline.id }}</gl-link
>
&nbsp;
</template>
<template #timestamp>
<span v-gl-tooltip :title="tooltipTitle(packagePipeline.created_at)">
&nbsp;{{ timeFormatted(packagePipeline.created_at) }}&nbsp;
</span>
</template>
<template #author
>{{ packagePipeline.user.name }}
<gl-avatar
class="prepend-left-8 d-none d-sm-block"
:src="packagePipeline.user.avatar_url"
:size="24"
/></template>
</gl-sprintf>
</div>
</div>
<div class="well-segment d-flex align-items-center">
<gl-icon name="clock" class="append-right-8 d-none d-sm-block" />
<gl-sprintf :message="$options.i18n.publishText">
<template #timestamp>
{{ publishedDate }}
</template>
</gl-sprintf>
</div>
</div>
</div>
</template>
......@@ -11,6 +11,7 @@ import {
} from '@gitlab/ui';
import _ from 'underscore';
import Tracking from '~/tracking';
import PackageActivity from './activity.vue';
import PackageInformation from './information.vue';
import PackageTitle from './package_title.vue';
import ConanInstallation from './conan_installation.vue';
......@@ -34,6 +35,7 @@ export default {
GlModal,
GlTable,
GlIcon,
PackageActivity,
PackageInformation,
PackageTitle,
ConanInstallation,
......@@ -214,6 +216,8 @@ export default {
</div>
</div>
<package-activity />
<gl-table
:fields="$options.filesTableHeaderFields"
:items="filesTableRows"
......
<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: {
......@@ -28,17 +24,6 @@ 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>
......@@ -61,30 +46,6 @@ 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>
......@@ -7,18 +7,16 @@ Vue.use(Translate);
export default () => {
const el = document.querySelector('#js-vue-packages-detail');
const {
package: packageJson,
packageFiles: packageFilesJson,
canDelete: canDeleteStr,
...rest
} = el.dataset;
const { package: packageJson, canDelete: canDeleteStr, ...rest } = el.dataset;
const packageEntity = JSON.parse(packageJson);
const packageFiles = JSON.parse(packageFilesJson);
const canDelete = canDeleteStr === 'true';
const store = createStore({ packageEntity, packageFiles, canDelete, ...rest });
store.dispatch('fetchPipelineInfo');
const store = createStore({
packageEntity,
packageFiles: packageEntity.package_files,
canDelete,
...rest,
});
// eslint-disable-next-line no-new
new Vue({
......
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');
});
}
};
......@@ -2,12 +2,8 @@ import { s__ } from '~/locale';
import { generateConanRecipe } from '../utils';
import { NpmManager } from '../constants';
export const packageHasPipeline = ({ packageEntity }) => {
if (packageEntity?.build_info?.pipeline_id) {
return true;
}
return false;
export const packagePipeline = ({ packageEntity }) => {
return packageEntity?.pipeline || null;
};
export const packageTypeDisplay = ({ packageEntity }) => {
......
import Vue from 'vue';
import Vuex from 'vuex';
import state from './state';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
......@@ -9,7 +8,6 @@ Vue.use(Vuex);
export default (initialState = {}) =>
new Vuex.Store({
actions,
getters,
mutations,
state: {
......
......@@ -23,5 +23,11 @@ module EE
package_registry_project_path = "#{project_api_path}/packages/#{registry_type}"
expose_url(package_registry_project_path)
end
def package_from_presenter(package)
presenter = ::Packages::Detail::PackagePresenter.new(package)
presenter.detail_view
end
end
end
# frozen_string_literal: true
module Packages
module Detail
class PackagePresenter
def initialize(package)
@package = package
end
def detail_view
package_detail = {
created_at: @package.created_at,
name: @package.name,
package_files: @package.package_files.as_json(methods: :download_path),
package_type: @package.package_type,
project_id: @package.project_id,
tags: @package.tags.as_json,
updated_at: @package.updated_at,
version: @package.version,
maven_metadatum: @package.maven_metadatum
}
if @package.build_info
package_detail[:pipeline] = @package.build_info.pipeline.as_json(
only: [:created_at, :git_commit_message, :id, :sha],
include: [user: { methods: :avatar_url, only: [:avatar_url, :name] }],
methods: :git_commit_message
)
end
package_detail.to_json
end
end
end
end
......@@ -5,8 +5,7 @@
.row
.col-12
#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),
#js-vue-packages-detail{ data: { package: package_from_presenter(@package),
can_delete: can?(current_user, :destroy_package, @project).to_s,
destroy_path: project_package_path(@project, @package),
svg_path: image_path('illustrations/no-packages.svg'),
......@@ -17,5 +16,4 @@
conan_path: package_registry_instance_url(:conan),
conan_help_path: help_page_path('user/packages/conan_repository/index'),
nuget_path: nuget_package_registry_url(@project.id),
nuget_help_path: help_page_path('user/packages/nuget_repository/index'),
package_file_download_path: download_project_package_file_path(@project, @package_files.first) } }
nuget_help_path: help_page_path('user/packages/nuget_repository/index') } }
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PackageActivity render to match the default snapshot when no pipeline 1`] = `
<div
class="append-bottom-default"
>
<h3
class="gl-font-size-16"
>
Activity
</h3>
<div
class="info-well"
>
<!---->
<!---->
<div
class="well-segment d-flex align-items-center"
>
<gl-icon-stub
class="append-right-8 d-none d-sm-block"
name="clock"
size="16"
/>
<gl-sprintf-stub
message="Published to the repository at %{timestamp}"
/>
</div>
</div>
</div>
`;
exports[`PackageActivity render to match the default snapshot when there is a pipeline 1`] = `
<div
class="append-bottom-default"
>
<h3
class="gl-font-size-16"
>
Activity
</h3>
<div
class="info-well"
>
<div
class="well-segment"
>
<div
class="d-flex align-items-center"
>
<gl-icon-stub
class="d-none d-sm-block"
name="commit"
size="16"
/>
<!---->
<gl-link-stub
href="../../commit/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
>
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
</gl-link-stub>
<clipboard-button-stub
cssclass="border-0 text-secondary py-0"
text="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
title="Copy commit SHA"
tooltipplacement="top"
/>
</div>
<!---->
</div>
<div
class="well-segment"
>
<div
class="d-flex align-items-center"
>
<gl-icon-stub
class="append-right-8 d-none d-sm-block"
name="pipeline"
size="16"
/>
<gl-sprintf-stub
message="Pipeline %{linkStart}%{linkEnd} triggered %{timestamp} by %{author}"
/>
</div>
</div>
<div
class="well-segment d-flex align-items-center"
>
<gl-icon-stub
class="append-right-8 d-none d-sm-block"
name="clock"
size="16"
/>
<gl-sprintf-stub
message="Published to the repository at %{timestamp}"
/>
</div>
</div>
</div>
`;
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import PackageActivity from 'ee/packages/details/components/activity.vue';
import {
npmPackage,
mavenPackage as packageWithoutBuildInfo,
mockPipelineInfo,
} from '../../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('PackageActivity', () => {
let wrapper;
let store;
function createComponent(packageEntity = packageWithoutBuildInfo, pipelineInfo = null) {
store = new Vuex.Store({
state: {
packageEntity,
},
getters: {
packagePipeline: () => pipelineInfo,
},
});
wrapper = shallowMount(PackageActivity, {
localVue,
store,
});
}
const commitMessageToggle = () => wrapper.find({ ref: 'commit-message-toggle' });
const commitMessage = () => wrapper.find({ ref: 'commit-message' });
const commitInfo = () => wrapper.find({ ref: 'commit-info' });
const pipelineInfo = () => wrapper.find({ ref: 'pipeline-info' });
afterEach(() => {
if (wrapper) wrapper.destroy();
});
describe('render', () => {
it('to match the default snapshot when no pipeline', () => {
createComponent();
expect(wrapper.element).toMatchSnapshot();
});
it('to match the default snapshot when there is a pipeline', () => {
createComponent(npmPackage, mockPipelineInfo);
expect(wrapper.element).toMatchSnapshot();
});
});
describe('commit message toggle', () => {
it("does not display the commit message button when there isn't one", () => {
createComponent(npmPackage, mockPipelineInfo);
expect(commitMessageToggle().exists()).toBe(false);
expect(commitMessage().exists()).toBe(false);
});
it('displays the commit message on toggle', () => {
const commitMessageStr = 'a message';
createComponent(npmPackage, {
...mockPipelineInfo,
git_commit_message: commitMessageStr,
});
commitMessageToggle().trigger('click');
return wrapper.vm.$nextTick(() => expect(commitMessage().text()).toBe(commitMessageStr));
});
});
describe('pipeline information', () => {
it('does not display pipeline information when no build info is available', () => {
createComponent();
expect(pipelineInfo().exists()).toBe(false);
});
it('displays the pipeline information if found', () => {
createComponent(npmPackage, mockPipelineInfo);
expect(commitInfo().exists()).toBe(true);
expect(pipelineInfo().exists()).toBe(true);
});
});
});
......@@ -34,8 +34,6 @@ describe('PackagesApp', () => {
isLoading: false,
packageEntity,
packageFiles,
pipelineInfo: {},
pipelineError: null,
canDelete: true,
destroyPath: 'destroy-package-path',
emptySvgPath: 'empty-illustration',
......
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
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: [
......@@ -29,34 +22,14 @@ describe('PackageInformation', () => {
],
};
function createComponent(
props = {},
packageEntity = packageWithoutBuildInfo,
hasPipeline = false,
isLoading = false,
pipelineError = null,
) {
function createComponent(props = {}) {
const propsData = {
...defaultProps,
...props,
};
store = new Vuex.Store({
state: {
isLoading,
packageEntity,
pipelineInfo: {},
pipelineError,
},
getters: {
packageHasPipeline: () => hasPipeline,
},
});
wrapper = shallowMount(PackageInformation, {
localVue,
propsData,
store,
});
}
......@@ -67,10 +40,6 @@ 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();
......@@ -119,36 +88,4 @@ 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 {
conanInstallationCommand,
conanSetupCommand,
packageHasPipeline,
packagePipeline,
packageTypeDisplay,
mavenInstallationXml,
mavenInstallationCommand,
......@@ -30,12 +30,8 @@ import { NpmManager } from 'ee/packages/details/constants';
describe('Getters PackageDetails Store', () => {
let state;
const mockPipelineError = 'mock-pipeline-error';
const defaultState = {
packageEntity: packageWithoutBuildInfo,
pipelineInfo: mockPipelineInfo,
pipelineError: mockPipelineError,
conanPath: registryUrl,
mavenPath: registryUrl,
npmPath: registryUrl,
......@@ -65,19 +61,22 @@ describe('Getters PackageDetails Store', () => {
const nugetInstallationCommandStr = `nuget install ${nugetPackage.name} -Source "GitLab"`;
const nugetSetupCommandStr = `nuget source Add -Name "GitLab" -Source "${registryUrl}" -UserName <your_username> -Password <your_token>`;
describe('packageHasPipeline', () => {
it('should return true when build_info and pipeline_id exist', () => {
describe('packagePipeline', () => {
it('should return the pipeline info when pipeline exists', () => {
setupState({
packageEntity: npmPackage,
packageEntity: {
...npmPackage,
pipeline: mockPipelineInfo,
},
});
expect(packageHasPipeline(state)).toEqual(true);
expect(packagePipeline(state)).toEqual(mockPipelineInfo);
});
it('should return false when build_info does not exist', () => {
it('should return null when build_info does not exist', () => {
setupState();
expect(packageHasPipeline(state)).toEqual(false);
expect(packagePipeline(state)).toBe(null);
});
});
......
......@@ -3,6 +3,12 @@ const _links = {
delete_api_path: 'bar',
};
export const mockPipelineInfo = {
id: 1,
ref: 'branch-name',
sha: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
};
export const mavenPackage = {
created_at: '2015-12-10',
id: 1,
......@@ -49,6 +55,7 @@ export const npmPackage = {
_links,
build_info: {
pipeline_id: 1,
pipeline: mockPipelineInfo,
},
};
......@@ -108,10 +115,3 @@ export const mockTags = [
];
export const packageList = [mavenPackage, { ...npmPackage, tags: mockTags }, conanPackage];
export const mockPipelineInfo = {
id: 1,
ref: 'branch-name',
sha: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
web_url: 'foo',
};
......@@ -13605,22 +13605,22 @@ msgstr ""
msgid "PackageRegistry|NuGet Command"
msgstr ""
msgid "PackageRegistry|Registry Setup"
msgid "PackageRegistry|Pipeline %{linkStart}%{linkEnd} triggered %{timestamp} by %{author}"
msgstr ""
msgid "PackageRegistry|Remove package"
msgid "PackageRegistry|Published to the repository at %{timestamp}"
msgstr ""
msgid "PackageRegistry|There are no packages yet"
msgid "PackageRegistry|Registry Setup"
msgstr ""
msgid "PackageRegistry|There was a problem fetching the details for this package."
msgid "PackageRegistry|Remove package"
msgstr ""
msgid "PackageRegistry|There was an error fetching the pipeline information."
msgid "PackageRegistry|There are no packages yet"
msgstr ""
msgid "PackageRegistry|Unable to fetch pipeline information"
msgid "PackageRegistry|There was a problem fetching the details for this package."
msgstr ""
msgid "PackageRegistry|Unable to load package"
......
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