Commit 0cd90487 authored by Mark Florian's avatar Mark Florian

Merge branch '197962-add-versions-tab-to-package-detail' into 'master'

Add versions tab to package details page

See merge request gitlab-org/gitlab!31940
parents 941621b7 46c0c4a6
......@@ -7,6 +7,8 @@ import {
GlTooltipDirective,
GlLink,
GlEmptyState,
GlTab,
GlTabs,
GlTable,
} from '@gitlab/ui';
import { escape } from 'lodash';
......@@ -19,13 +21,15 @@ import MavenInstallation from './maven_installation.vue';
import NpmInstallation from './npm_installation.vue';
import NugetInstallation from './nuget_installation.vue';
import PypiInstallation from './pypi_installation.vue';
import PackagesListLoader from '../../shared/components/packages_list_loader.vue';
import PackageListRow from '../../shared/components/package_list_row.vue';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { generatePackageInfo } from '../utils';
import { __, s__, sprintf } from '~/locale';
import { PackageType, TrackingActions } from '../../shared/constants';
import { packageTypeToTrackCategory } from '../../shared/utils';
import { mapState } from 'vuex';
import { mapActions, mapState } from 'vuex';
export default {
name: 'PackagesApp',
......@@ -34,6 +38,8 @@ export default {
GlEmptyState,
GlLink,
GlModal,
GlTab,
GlTabs,
GlTable,
GlIcon,
PackageActivity,
......@@ -44,6 +50,8 @@ export default {
NpmInstallation,
NugetInstallation,
PypiInstallation,
PackagesListLoader,
PackageListRow,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -55,6 +63,7 @@ export default {
...mapState([
'packageEntity',
'packageFiles',
'isLoading',
'canDelete',
'destroyPath',
'svgPath',
......@@ -142,14 +151,23 @@ export default {
category: packageTypeToTrackCategory(this.packageEntity.package_type),
};
},
hasVersions() {
return this.packageEntity.versions?.length > 0;
},
},
methods: {
...mapActions(['fetchPackageVersions']),
formatSize(size) {
return numberToHumanSize(size);
},
cancelDelete() {
this.$refs.deleteModal.hide();
},
getPackageVersions() {
if (!this.packageEntity.versions) {
this.fetchPackageVersions();
}
},
},
i18n: {
deleteModalTitle: s__(`PackageRegistry|Delete Package Version`),
......@@ -197,52 +215,83 @@ export default {
</div>
</div>
<div class="row prepend-top-default" data-qa-selector="package_information_content">
<div class="col-sm-6">
<package-information :information="packageInformation" />
<package-information
v-if="packageMetadata"
:heading="packageMetadataTitle"
:information="packageMetadata"
:show-copy="true"
/>
</div>
<gl-tabs>
<gl-tab :title="__('Detail')">
<div class="row" data-qa-selector="package_information_content">
<div class="col-sm-6">
<package-information :information="packageInformation" />
<package-information
v-if="packageMetadata"
:heading="packageMetadataTitle"
:information="packageMetadata"
:show-copy="true"
/>
</div>
<div class="col-sm-6">
<component
:is="installationComponent"
v-if="installationComponent"
:name="packageEntity.name"
:registry-url="npmPath"
:help-url="npmHelpPath"
/>
</div>
</div>
<div class="col-sm-6">
<component
:is="installationComponent"
v-if="installationComponent"
:name="packageEntity.name"
:registry-url="npmPath"
:help-url="npmHelpPath"
/>
</div>
</div>
<package-activity />
<package-activity />
<gl-table
:fields="$options.filesTableHeaderFields"
:items="filesTableRows"
tbody-tr-class="js-file-row"
>
<template #cell(name)="items">
<gl-icon name="doc-code" class="space-right" />
<gl-link
:href="items.item.downloadPath"
class="js-file-download"
@click="track($options.trackingActions.PULL_PACKAGE)"
<gl-table
:fields="$options.filesTableHeaderFields"
:items="filesTableRows"
tbody-tr-class="js-file-row"
>
{{ items.item.name }}
</gl-link>
</template>
<template #cell(name)="items">
<gl-icon name="doc-code" class="space-right" />
<gl-link
:href="items.item.downloadPath"
class="js-file-download"
@click="track($options.trackingActions.PULL_PACKAGE)"
>
{{ items.item.name }}
</gl-link>
</template>
<template #cell(created)="items">
<span v-gl-tooltip :title="tooltipTitle(items.item.created)">{{
timeFormatted(items.item.created)
}}</span>
</template>
</gl-table>
</gl-tab>
<gl-tab
:title="__('Versions')"
title-item-class="js-versions-tab"
@click="getPackageVersions"
>
<template v-if="isLoading && !hasVersions">
<packages-list-loader />
</template>
<template v-else-if="hasVersions">
<package-list-row
v-for="v in packageEntity.versions"
:key="v.id"
:package-entity="{ name: packageEntity.name, ...v }"
:package-link="v.id.toString()"
:disable-delete="true"
:show-package-type="false"
/>
</template>
<template #cell(created)="items">
<span v-gl-tooltip :title="tooltipTitle(items.item.created)">{{
timeFormatted(items.item.created)
}}</span>
</template>
</gl-table>
<template v-else class="gl-mt-3">
<p data-testid="no-versions-message">
{{ s__('PackageRegistry|There are no other versions of this package.') }}
</p>
</template>
</gl-tab>
</gl-tabs>
<gl-modal ref="deleteModal" class="js-delete-modal" modal-id="delete-modal">
<template #modal-title>{{ $options.i18n.deleteModalTitle }}</template>
......
import { s__ } from '~/locale';
export const TrackingLabels = {
CODE_INSTRUCTION: 'code_instruction',
CONAN_INSTALLATION: 'conan_installation',
......@@ -35,3 +37,7 @@ export const NpmManager = {
NPM: 'npm',
YARN: 'yarn',
};
export const FETCH_PACKAGE_VERSIONS_ERROR = s__(
'PackageRegistry|Unable to fetch package version information.',
);
import Api from 'ee/api';
import createFlash from '~/flash';
import { FETCH_PACKAGE_VERSIONS_ERROR } from '../constants';
import * as types from './mutation_types';
export default ({ commit, state }) => {
commit(types.SET_LOADING, true);
const { project_id, id } = state.packageEntity;
return Api.projectPackage(project_id, id)
.then(({ data }) => {
if (data.versions) {
commit(types.SET_PACKAGE_VERSIONS, data.versions.reverse());
}
})
.catch(() => {
createFlash(FETCH_PACKAGE_VERSIONS_ERROR);
})
.finally(() => {
commit(types.SET_LOADING, false);
});
};
import Vue from 'vue';
import Vuex from 'vuex';
import fetchPackageVersions from './actions';
import * as getters from './getters';
import mutations from './mutations';
Vue.use(Vuex);
export default (initialState = {}) =>
new Vuex.Store({
actions: {
fetchPackageVersions,
},
getters,
mutations,
state: {
isLoading: false,
...initialState,
},
});
export const SET_LOADING = 'SET_LOADING';
export const SET_PACKAGE_VERSIONS = 'SET_PACKAGE_VERSIONS';
import * as types from './mutation_types';
export default {
[types.SET_LOADING](state, isLoading) {
state.isLoading = isLoading;
},
[types.SET_PACKAGE_VERSIONS](state, versions) {
state.packageEntity = {
...state.packageEntity,
versions,
};
},
};
export default () => ({
packageEntity: null,
packageFiles: [],
});
......@@ -9,6 +9,7 @@ module Packages
def detail_view
package_detail = {
id: @package.id,
created_at: @package.created_at,
name: @package.name,
package_files: @package.package_files.map { |pf| build_package_file_view(pf) },
......
---
title: Adds versions tab and additional versions list to packages details page
merge_request: 31940
author:
type: added
......@@ -10,6 +10,8 @@ import NpmInstallation from 'ee/packages/details/components/npm_installation.vue
import MavenInstallation from 'ee/packages/details/components/maven_installation.vue';
import * as SharedUtils from 'ee/packages/shared/utils';
import { TrackingActions } from 'ee/packages/shared/constants';
import PackagesListLoader from 'ee/packages/shared/components/packages_list_loader.vue';
import PackageListRow from 'ee/packages/shared/components/package_list_row.vue';
import ConanInstallation from 'ee/packages/details/components/conan_installation.vue';
import NugetInstallation from 'ee/packages/details/components/nuget_installation.vue';
import PypiInstallation from 'ee/packages/details/components/pypi_installation.vue';
......@@ -30,11 +32,16 @@ localVue.use(Vuex);
describe('PackagesApp', () => {
let wrapper;
let store;
const fetchPackageVersions = jest.fn();
function createComponent(packageEntity = mavenPackage, packageFiles = mavenFiles) {
function createComponent({
packageEntity = mavenPackage,
packageFiles = mavenFiles,
isLoading = false,
} = {}) {
store = new Vuex.Store({
state: {
isLoading: false,
isLoading,
packageEntity,
packageFiles,
canDelete: true,
......@@ -43,6 +50,9 @@ describe('PackagesApp', () => {
npmPath: 'foo',
npmHelpPath: 'foo',
},
actions: {
fetchPackageVersions,
},
getters,
});
......@@ -54,6 +64,8 @@ describe('PackagesApp', () => {
GlDeprecatedButton: false,
GlLink: false,
GlModal: false,
GlTab: false,
GlTabs: false,
GlTable: false,
},
});
......@@ -73,6 +85,10 @@ describe('PackagesApp', () => {
const deleteButton = () => wrapper.find('.js-delete-button');
const deleteModal = () => wrapper.find(GlModal);
const modalDeleteButton = () => wrapper.find({ ref: 'modal-delete-button' });
const versionsTab = () => wrapper.find('.js-versions-tab > a');
const packagesLoader = () => wrapper.find(PackagesListLoader);
const packagesVersionRows = () => wrapper.findAll(PackageListRow);
const noVersionsMessage = () => wrapper.find('[data-testid="no-versions-message"]');
afterEach(() => {
wrapper.destroy();
......@@ -100,7 +116,7 @@ describe('PackagesApp', () => {
});
it('does not render package metadata for npm as npm packages do not contain metadata', () => {
createComponent(npmPackage, npmFiles);
createComponent({ packageEntity: npmPackage, packageFiles: npmFiles });
expect(packageInformation(0)).toExist();
expect(allPackageInformation()).toHaveLength(1);
......@@ -124,7 +140,7 @@ describe('PackagesApp', () => {
});
it('renders a single file for an npm package as they only contain one file', () => {
createComponent(npmPackage, npmFiles);
createComponent({ packageEntity: npmPackage, packageFiles: npmFiles });
expect(allFileRows()).toExist();
expect(allFileRows()).toHaveLength(1);
......@@ -155,6 +171,38 @@ describe('PackagesApp', () => {
});
});
describe('versions', () => {
describe('api call', () => {
beforeEach(() => {
createComponent();
});
it('makes api request on first click of tab', () => {
versionsTab().trigger('click');
expect(fetchPackageVersions).toHaveBeenCalled();
});
});
it('displays the loader when state is loading', () => {
createComponent({ isLoading: true });
expect(packagesLoader().exists()).toBe(true);
});
it('displays the correct version count when the package has versions', () => {
createComponent({ packageEntity: npmPackage });
expect(packagesVersionRows()).toHaveLength(npmPackage.versions.length);
});
it('displays the no versions message when there are none', () => {
createComponent();
expect(noVersionsMessage().exists()).toBe(true);
});
});
describe('tracking', () => {
let eventSpy;
let utilSpy;
......@@ -166,13 +214,13 @@ describe('PackagesApp', () => {
});
it('tracking category calls packageTypeToTrackCategory', () => {
createComponent(conanPackage);
createComponent({ packageEntity: 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(conanPackage);
createComponent({ packageEntity: conanPackage });
deleteButton().trigger('click');
return wrapper.vm.$nextTick().then(() => {
modalDeleteButton().trigger('click');
......@@ -185,7 +233,7 @@ describe('PackagesApp', () => {
});
it(`file download link call event with ${TrackingActions.PULL_PACKAGE}`, () => {
createComponent(conanPackage);
createComponent({ packageEntity: conanPackage });
firstFileDownloadLink().trigger('click');
expect(eventSpy).toHaveBeenCalledWith(
category,
......
import Api from 'ee/api';
import createFlash from '~/flash';
import fetchPackageVersions from 'ee/packages/details/store/actions';
import * as types from 'ee/packages/details/store/mutation_types';
import { FETCH_PACKAGE_VERSIONS_ERROR } from 'ee/packages/details/constants';
import testAction from 'helpers/vuex_action_helper';
import { npmPackage as packageEntity } from '../../mock_data';
jest.mock('~/flash.js');
jest.mock('ee/api.js');
describe('Actions Package details store', () => {
describe('fetchPackageVersions', () => {
it('should fetch the package versions', done => {
Api.projectPackage = jest.fn().mockResolvedValue({ data: packageEntity });
testAction(
fetchPackageVersions,
undefined,
{ packageEntity },
[
{ type: types.SET_LOADING, payload: true },
{ type: types.SET_PACKAGE_VERSIONS, payload: packageEntity.versions },
{ type: types.SET_LOADING, payload: false },
],
[],
() => {
expect(Api.projectPackage).toHaveBeenCalledWith(
packageEntity.project_id,
packageEntity.id,
);
done();
},
);
});
it("does not set the versions if they don't exist", done => {
Api.projectPackage = jest.fn().mockResolvedValue({ data: { packageEntity, versions: null } });
testAction(
fetchPackageVersions,
undefined,
{ packageEntity },
[{ type: types.SET_LOADING, payload: true }, { type: types.SET_LOADING, payload: false }],
[],
() => {
expect(Api.projectPackage).toHaveBeenCalledWith(
packageEntity.project_id,
packageEntity.id,
);
done();
},
);
});
it('should create flash on API error', done => {
Api.projectPackage = jest.fn().mockRejectedValue();
testAction(
fetchPackageVersions,
undefined,
{ packageEntity },
[{ type: types.SET_LOADING, payload: true }, { type: types.SET_LOADING, payload: false }],
[],
() => {
expect(Api.projectPackage).toHaveBeenCalledWith(
packageEntity.project_id,
packageEntity.id,
);
expect(createFlash).toHaveBeenCalledWith(FETCH_PACKAGE_VERSIONS_ERROR);
done();
},
);
});
});
});
import mutations from 'ee/packages/details/store/mutations';
import * as types from 'ee/packages/details/store/mutation_types';
import { npmPackage as packageEntity } from '../../mock_data';
describe('Mutations package details Store', () => {
let mockState;
beforeEach(() => {
mockState = {
packageEntity,
};
});
describe('SET_LOADING', () => {
it('should set loading', () => {
mutations[types.SET_LOADING](mockState, true);
expect(mockState.isLoading).toEqual(true);
});
});
describe('SET_PACKAGE_VERSIONS', () => {
it('should set the package entity versions', () => {
const fakeVersions = [1, 2, 3];
mutations[types.SET_PACKAGE_VERSIONS](mockState, fakeVersions);
expect(mockState.packageEntity.versions).toEqual(fakeVersions);
});
});
});
......@@ -59,6 +59,7 @@ export const npmPackage = {
project_id: 1,
updated_at: '2015-12-10',
version: '',
versions: [],
_links,
pipeline: mockPipelineInfo,
};
......
......@@ -35,6 +35,7 @@ describe ::Packages::Detail::PackagePresenter do
end
let!(:expected_package_details) do
{
id: package.id,
created_at: package.created_at,
name: package.name,
package_files: expected_package_files,
......
......@@ -7416,6 +7416,9 @@ msgstr ""
msgid "Destroy"
msgstr ""
msgid "Detail"
msgstr ""
msgid "Details"
msgstr ""
......@@ -14986,6 +14989,9 @@ msgstr ""
msgid "PackageRegistry|There are no %{packageType} packages yet"
msgstr ""
msgid "PackageRegistry|There are no other versions of this package."
msgstr ""
msgid "PackageRegistry|There are no packages yet"
msgstr ""
......@@ -14998,6 +15004,9 @@ msgstr ""
msgid "PackageRegistry|To widen your search, change or remove the filters above."
msgstr ""
msgid "PackageRegistry|Unable to fetch package version information."
msgstr ""
msgid "PackageRegistry|Unable to load package"
msgstr ""
......@@ -23745,6 +23754,9 @@ msgstr ""
msgid "Version"
msgstr ""
msgid "Versions"
msgstr ""
msgid "Very helpful"
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