Commit 1208a21b authored by Ash McKenzie's avatar Ash McKenzie

Merge branch '8248-conan-package-ui-existing-to-vue' into 'master'

Update package details UI to be Vue

See merge request gitlab-org/gitlab-ee!15574
parents 4ccd8025 496f93ee
<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 ...@@ -29,4 +29,8 @@ class Packages::PackageFile < ApplicationRecord
# Keep empty for now. Should be addressed in future # Keep empty for now. Should be addressed in future
# by https://gitlab.com/gitlab-org/gitlab-ee/issues/7891 # by https://gitlab.com/gitlab-org/gitlab-ee/issues/7891
end end
def download_path
Gitlab::Routing.url_helpers.download_project_package_file_path(project, self)
end
end end
...@@ -3,73 +3,11 @@ ...@@ -3,73 +3,11 @@
- breadcrumb_title @package.version - breadcrumb_title @package.version
- page_title _("Packages") - page_title _("Packages")
.detail-page-header.d-flex.justify-content-between .row
%strong .col-12
= @package.version #js-vue-packages-detail{ data: { package: @package.to_json(include: [:maven_metadatum, :package_files]),
package_files: @package_files.to_json(methods: :download_path),
- if can?(current_user, :destroy_package, @project) can_delete: can?(current_user, :destroy_package, @project).to_s,
= link_to project_package_path(@project, @package), method: :delete, data: { confirm: _("Are you sure?") }, class: "btn btn-grouped btn-remove", title: _('Delete Package') do destroy_path: project_package_path(@project, @package),
= _('Delete') svg_path: image_path('illustrations/no-packages.svg'),
.row.prepend-top-default package_file_download_path: download_project_package_file_path(@project, @package_files.first) } }
.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)
...@@ -18,20 +18,18 @@ describe 'PackageFiles' do ...@@ -18,20 +18,18 @@ describe 'PackageFiles' do
project.add_master(user) project.add_master(user)
end end
it 'allows file download from package page' do it 'allows direct download by url' do
visit project_package_path(project, package) visit download_project_package_file_path(project, package_file)
click_link package_file.file_name
expect(status_code).to eq(200) 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 end
it 'allows direct download by url' do it 'renders the download link with the correct url', :js do
visit download_project_package_file_path(project, package_file) 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 end
it 'does not allow download of package belonging to different project' do it 'does not allow download of package belonging to different project' do
......
...@@ -64,7 +64,7 @@ describe 'Packages' do ...@@ -64,7 +64,7 @@ describe 'Packages' do
expect(page).not_to have_content(package.name) expect(page).not_to have_content(package.name)
end end
it 'shows a single package' do it 'shows a single package', :js do
click_on package.name click_on package.name
expect(page).to have_content(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 "" ...@@ -10615,6 +10615,18 @@ msgstr ""
msgid "Package was removed" msgid "Package was removed"
msgstr "" 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" msgid "Packages"
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