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