Commit 496f93ee authored by Nick Kipling's avatar Nick Kipling Committed by Ash McKenzie

Updated package detail page to use vue

Created new vue app for package detail
Added JS to support new app
Updated haml to load new vue app
Added new jest tests for vue app
Updated pot file
parent 4ccd8025
<script>
import {
GlButton,
GlModal,
GlModalDirective,
GlTooltipDirective,
GlLink,
GlEmptyState,
} from '@gitlab/ui';
import _ from 'underscore';
import PackageInformation from './information.vue';
import Icon from '~/vue_shared/components/icon.vue';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { formatDate } from '~/lib/utils/datetime_utility';
import { s__, sprintf } from '~/locale';
import PackageType from '../constants';
export default {
name: 'PackagesApp',
components: {
GlButton,
GlEmptyState,
GlLink,
GlModal,
Icon,
PackageInformation,
},
directives: {
GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
},
mixins: [timeagoMixin],
props: {
packageEntity: {
type: Object,
required: true,
},
files: {
type: Array,
default: () => [],
required: true,
},
canDelete: {
type: Boolean,
default: false,
required: true,
},
destroyPath: {
type: String,
default: '',
required: true,
},
emptySvgPath: {
type: String,
required: true,
},
},
computed: {
isValidPackage() {
if (this.packageEntity.name) {
return true;
}
return false;
},
canDeletePackage() {
return this.canDelete && this.destroyPath;
},
deleteModalDescription() {
return sprintf(
s__(
`PackageRegistry|You are about to delete version %{boldStart}%{version}%{boldEnd} of %{boldStart}%{name}%{boldEnd}. Are you sure?`,
),
{
version: _.escape(this.packageEntity.version),
name: _.escape(this.packageEntity.name),
boldStart: '<b>',
boldEnd: '</b>',
},
false,
);
},
packageInformation() {
return [
{
label: s__('Name'),
value: this.packageEntity.name,
},
{
label: s__('Version'),
value: this.packageEntity.version,
},
{
label: s__('Created on'),
value: formatDate(this.packageEntity.created_at),
},
];
},
packageMetadataTitle() {
switch (this.packageEntity.package_type) {
case PackageType.MAVEN:
return s__('Maven Metadata');
default:
return s__('Package information');
}
},
packageMetadata() {
switch (this.packageEntity.package_type) {
case PackageType.MAVEN:
return [
{
label: s__('Group ID'),
value: this.packageEntity.maven_metadatum.app_group,
},
{
label: s__('Artifact ID'),
value: this.packageEntity.maven_metadatum.app_name,
},
{
label: s__('Version'),
value: this.packageEntity.maven_metadatum.app_version,
},
];
default:
return null;
}
},
},
methods: {
formatSize(size) {
return numberToHumanSize(size);
},
cancelDelete() {
this.$refs.deleteModal.hide();
},
},
i18n: {
deleteModalTitle: s__(`PackageRegistry|Delete Package Version`),
},
};
</script>
<template>
<gl-empty-state
v-if="!isValidPackage"
:title="s__('PackageRegistry|Unable to load package')"
:description="s__('PackageRegistry|There was a problem fetching the details for this package.')"
:svg-path="emptySvgPath"
class="js-package-empty-state"
/>
<div v-else class="packages-app">
<div class="detail-page-header d-flex justify-content-between">
<strong class="js-version-title">{{ packageEntity.version }}</strong>
<gl-button
v-if="canDeletePackage"
v-gl-modal="'delete-modal'"
class="js-delete-button"
variant="danger"
>{{ __('Delete') }}</gl-button
>
</div>
<div class="row prepend-top-default">
<package-information :type="packageEntity.package_type" :information="packageInformation" />
<package-information
v-if="packageMetadata"
:heading="packageMetadataTitle"
:information="packageMetadata"
/>
</div>
<table class="table">
<thead>
<tr>
<th>{{ __('Name') }}</th>
<th>{{ __('Size') }}</th>
<th>
<span class="pull-right">{{ __('Created') }}</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="file in files" :key="file.id" class="js-file-row">
<td class="d-flex align-items-center">
<icon name="doc-code" class="space-right" /><gl-link
:href="file.download_path"
class="js-file-download"
>{{ file.file_name }}</gl-link
>
</td>
<td>{{ formatSize(file.size) }}</td>
<td>
<span v-gl-tooltip class="pull-right" :title="tooltipTitle(file.created_at)">{{
timeFormated(file.created_at)
}}</span>
</td>
</tr>
</tbody>
</table>
<gl-modal ref="deleteModal" class="js-delete-modal" modal-id="delete-modal">
<template v-slot:modal-title>{{ $options.i18n.deleteModalTitle }}</template>
<p v-html="deleteModalDescription"></p>
<div slot="modal-footer" class="w-100">
<div class="float-right">
<gl-button @click="cancelDelete()">{{ __('Cancel') }}</gl-button>
<gl-button data-method="delete" :to="destroyPath" variant="danger">{{
__('Delete')
}}</gl-button>
</div>
</div>
</gl-modal>
</div>
</template>
<script>
import { s__ } from '~/locale';
export default {
name: 'PackageInformation',
props: {
heading: {
type: String,
default: s__('Package information'),
required: false,
},
information: {
type: Array,
default: () => [],
required: true,
},
},
};
</script>
<template>
<div class="col-sm-6">
<div class="card">
<div class="card-header">
<strong>{{ heading }}</strong>
</div>
<ul class="content-list">
<li v-for="(item, index) in information" :key="index">
<span class="text-secondary">{{ item.label }}</span>
<span class="pull-right">{{ item.value }}</span>
</li>
</ul>
</div>
</div>
</template>
const PackageType = {
MAVEN: 'maven',
NPM: 'npm',
};
export default PackageType;
import Vue from 'vue';
import PackagesApp from './components/app.vue';
import Translate from '~/vue_shared/translate';
Vue.use(Translate);
export default () =>
new Vue({
el: '#js-vue-packages-detail',
components: {
PackagesApp,
},
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,
};
},
render(createElement) {
return createElement('packages-app', {
props: {
packageEntity: this.packageData,
files: this.packageFiles,
canDelete: this.canDelete,
destroyPath: this.destroyPath,
emptySvgPath: this.emptySvgPath,
},
});
},
});
import initPackageDetail from 'ee/packages';
document.addEventListener('DOMContentLoaded', initPackageDetail);
......@@ -29,4 +29,8 @@ class Packages::PackageFile < ApplicationRecord
# Keep empty for now. Should be addressed in future
# by https://gitlab.com/gitlab-org/gitlab-ee/issues/7891
end
def download_path
Gitlab::Routing.url_helpers.download_project_package_file_path(project, self)
end
end
......@@ -3,73 +3,11 @@
- breadcrumb_title @package.version
- page_title _("Packages")
.detail-page-header.d-flex.justify-content-between
%strong
= @package.version
- if can?(current_user, :destroy_package, @project)
= link_to project_package_path(@project, @package), method: :delete, data: { confirm: _("Are you sure?") }, class: "btn btn-grouped btn-remove", title: _('Delete Package') do
= _('Delete')
.row.prepend-top-default
.col-sm-6
.card
.card-header
%strong= _('Package information')
%ul.content-list
%li
%span.text-secondary
= _('Name')
%span.pull-right
= @package.name
%li
%span.text-secondary
= _('Version')
%span.pull-right
= @package.version
%li
%span.text-secondary
= _('Created on')
%span.pull-right
= @package.created_at.to_s(:medium)
.col-sm-6
- if @maven_metadatum
.card
.card-header
%strong= _('Maven Metadata')
%ul.content-list
%li
%span.text-secondary
= _('Group ID')
%span.pull-right
= @maven_metadatum.app_group
%li
%span.text-secondary
= _('Artifact ID')
%span.pull-right
= @maven_metadatum.app_name
%li
%span.text-secondary
= _('Version')
%span.pull-right
= @maven_metadatum.app_version
%table.table
%thead
%tr
%th
= _('Name')
%th
= _('Size')
%th
.pull-right
= _('Created')
%tbody
- @package_files.each do |package_file|
%tr
%td
= icon('file-o fw')
= link_to package_file.file.identifier, download_project_package_file_path(@project, package_file)
%td
= number_to_human_size(package_file.size, precision: 2)
%td
.pull-right
= time_ago_with_tooltip(package_file.created_at)
.row
.col-12
#js-vue-packages-detail{ data: { package: @package.to_json(include: [:maven_metadatum, :package_files]),
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),
svg_path: image_path('illustrations/no-packages.svg'),
package_file_download_path: download_project_package_file_path(@project, @package_files.first) } }
......@@ -18,20 +18,18 @@ describe 'PackageFiles' do
project.add_master(user)
end
it 'allows file download from package page' do
visit project_package_path(project, package)
click_link package_file.file_name
it 'allows direct download by url' do
visit download_project_package_file_path(project, package_file)
expect(status_code).to eq(200)
expect(page.response_headers['Content-Type']).to eq 'application/xml'
expect(page.response_headers['Content-Transfer-Encoding']).to eq 'binary'
end
it 'allows direct download by url' do
visit download_project_package_file_path(project, package_file)
it 'renders the download link with the correct url', :js do
visit project_package_path(project, package)
expect(status_code).to eq(200)
download_url = download_project_package_file_path(project, package_file)
expect(page).to have_link(package_file.file_name, href: download_url)
end
it 'does not allow download of package belonging to different project' do
......
......@@ -64,7 +64,7 @@ describe 'Packages' do
expect(page).not_to have_content(package.name)
end
it 'shows a single package' do
it 'shows a single package', :js do
click_on package.name
expect(page).to have_content(package.name)
......
import { mount } from '@vue/test-utils';
import { GlModal } from '@gitlab/ui';
import PackagesApp from 'ee/packages/components/app.vue';
import PackageInformation from 'ee/packages/components/information.vue';
import { mavenPackage, mavenFiles, npmPackage, npmFiles } from '../mock_data';
describe('PackagesApp', () => {
let wrapper;
const defaultProps = {
packageEntity: mavenPackage,
files: mavenFiles,
canDelete: true,
destroyPath: 'destroy-package-path',
emptySvgPath: 'empty-illustration',
};
function createComponent(props = {}) {
const propsData = {
...defaultProps,
...props,
};
wrapper = mount(PackagesApp, {
propsData,
});
}
const versionTitle = () => wrapper.find('.js-version-title');
const emptyState = () => wrapper.find('.js-package-empty-state');
const allPackageInformation = () => wrapper.findAll(PackageInformation);
const packageInformation = index => allPackageInformation().at(index);
const allFileRows = () => wrapper.findAll('.js-file-row');
const firstFileDownloadLink = () => wrapper.find('.js-file-download');
const deleteButton = () => wrapper.find('.js-delete-button');
const deleteModal = () => wrapper.find(GlModal);
afterEach(() => {
wrapper.destroy();
});
it('renders the app and displays the package version as the title', () => {
createComponent();
expect(versionTitle()).toExist();
expect(versionTitle().text()).toBe(mavenPackage.version);
});
it('renders an empty state component when no an invalid package is passed as a prop', () => {
createComponent({
packageEntity: {},
});
expect(emptyState()).toExist();
});
it('renders package information and metadata for packages containing both information and metadata', () => {
createComponent();
expect(packageInformation(0)).toExist();
expect(packageInformation(1)).toExist();
});
it('does not render package metadata for npm as npm packages do not contain metadata', () => {
createComponent({
packageEntity: npmPackage,
files: npmFiles,
});
expect(packageInformation(0)).toExist();
expect(allPackageInformation().length).toBe(1);
});
it('renders a single file for an npm package as they only contain one file', () => {
createComponent({
packageEntity: npmPackage,
files: npmFiles,
});
expect(allFileRows()).toExist();
expect(allFileRows().length).toBe(1);
});
it('renders multiple files for a package that contains more than one file', () => {
createComponent();
expect(allFileRows()).toExist();
expect(allFileRows().length).toBe(2);
});
it('allows the user to download a package file by rendering a download link', () => {
createComponent();
expect(allFileRows()).toExist();
expect(firstFileDownloadLink().vm.$attrs.href).toContain('download');
});
describe('deleting packages', () => {
beforeEach(() => {
createComponent();
deleteButton().trigger('click');
});
it('shows the delete confirmation modal when delete is clicked', () => {
expect(deleteModal()).toExist();
});
});
});
import { shallowMount } from '@vue/test-utils';
import PackageInformation from 'ee/packages/components/information.vue';
describe('PackageInformation', () => {
let wrapper;
const defaultProps = {
information: [
{
label: 'Information one',
value: 'Information value one',
},
{
label: 'Information two',
value: 'Information value two',
},
{
label: 'Information three',
value: 'Information value three',
},
],
};
function createComponent(props = {}) {
const propsData = {
...defaultProps,
...props,
};
wrapper = shallowMount(PackageInformation, {
propsData,
});
}
const headingSelector = () => wrapper.find('.card-header > strong');
const informationSelector = () => wrapper.findAll('ul.content-list li');
const informationRowText = index =>
informationSelector()
.at(index)
.text();
afterEach(() => {
if (wrapper) wrapper.destroy();
});
it('renders the information block with default heading', () => {
createComponent();
expect(headingSelector()).toExist();
expect(headingSelector().text()).toBe('Package information');
});
it('renders a custom supplied heading', () => {
const heading = 'A custom heading';
createComponent({
heading,
});
expect(headingSelector()).toExist();
expect(headingSelector().text()).toBe(heading);
});
it('renders the supplied information', () => {
createComponent();
expect(informationSelector().length).toBe(3);
expect(informationRowText(0)).toContain('one');
expect(informationRowText(1)).toContain('two');
expect(informationRowText(2)).toContain('three');
});
});
export const mavenPackage = {
created_at: '',
id: 1,
maven_metadatum: {
app_group: 'com.test.app',
app_name: 'test-app',
app_version: '1.0-SNAPSHOT',
},
name: 'Test package',
package_type: 'maven',
project_id: 1,
updated_at: '',
version: '1.0.0',
};
export const mavenFiles = [
{
created_at: '',
file_name: 'File one',
id: 1,
size: 100,
download_path: '/-/package_files/1/download',
},
{
created_at: '',
file_name: 'File two',
id: 2,
size: 200,
download_path: '/-/package_files/2/download',
},
];
export const npmPackage = {
created_at: '',
id: 2,
name: '@Test/package',
package_type: 'npm',
project_id: 1,
updated_at: '',
version: '',
};
export const npmFiles = [
{
created_at: '',
file_name: '@test/test-package-1.0.0.tgz',
id: 2,
size: 200,
download_path: '/-/package_files/2/download',
},
];
......@@ -10615,6 +10615,18 @@ msgstr ""
msgid "Package was removed"
msgstr ""
msgid "PackageRegistry|Delete Package Version"
msgstr ""
msgid "PackageRegistry|There was a problem fetching the details for this package."
msgstr ""
msgid "PackageRegistry|Unable to load package"
msgstr ""
msgid "PackageRegistry|You are about to delete version %{boldStart}%{version}%{boldEnd} of %{boldStart}%{name}%{boldEnd}. Are you sure?"
msgstr ""
msgid "Packages"
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