Commit b173b6ae authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '31072-package-list-as-vue' into 'master'

Create package list vue component

See merge request gitlab-org/gitlab!19613
parents 1686da10 2ccf2f4e
<script>
import { GlTable, GlPagination, GlButton, GlSorting, GlSortingItem, GlModal } from '@gitlab/ui';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import Icon from '~/vue_shared/components/icon.vue';
import { s__, sprintf } from '~/locale';
export default {
components: {
GlTable,
GlPagination,
GlSorting,
GlSortingItem,
GlButton,
TimeAgoTooltip,
GlModal,
Icon,
},
props: {
canDestroyPackage: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
modalId: 'confirm-delete-pacakge',
itemToBeDeleted: null,
};
},
computed: {
// the following computed properties are going to be connected to vuex
list() {
return [];
},
perPage() {
return 20;
},
totalItems() {
return 100;
},
currentPage: {
get() {
return 1;
},
set() {
// do something with value
},
},
orderBy() {
return 'name';
},
sort() {
return 'asc';
},
// end of vuex placeholder
sortText() {
const field = this.sortableFields.find(s => s.key === this.orderBy);
return field ? field.label : '';
},
isSortAscending() {
return this.sort === 'asc';
},
isListEmpty() {
return !this.list || this.list.length === 0;
},
showActions() {
return this.canDestroyPackage;
},
sortableFields() {
return [
{
key: 'name',
label: s__('Name'),
tdClass: ['w-25'],
},
{
key: 'version',
label: s__('Version'),
},
{
key: 'package_type',
label: s__('Type'),
},
{
key: 'created_at',
label: s__('Created'),
},
];
},
headerFields() {
const actions = {
key: 'actions',
label: '',
tdClass: ['text-right'],
};
return this.showActions ? [...this.sortableFields, actions] : this.sortableFields;
},
modalAction() {
return s__('PackageRegistry|Delete Package');
},
deletePackageDescription() {
if (!this.itemToBeDeleted) {
return '';
}
return sprintf(
s__(
'PackageRegistry|You are about to delete <b>%{packageName}</b>, this operation is irreversible, are you sure?',
),
{ packageName: this.itemToBeDeleted.name },
false,
);
},
},
methods: {
onDirectionChange() {},
onSortItemClick() {},
setItemToBeDeleted({ name, id }) {
this.itemToBeDeleted = { name, id };
this.$refs.packageListDeleteModal.show();
},
deleteItemConfirmation() {
// this is going to be connected to vuex action
this.itemToBeDeleted = null;
},
deleteItemCanceled() {
// this is going to be used to support ui tracking in the future
this.itemToBeDeleted = null;
},
},
};
</script>
<template>
<div class="d-flex flex-column align-items-end">
<slot v-if="isListEmpty" name="empty-state"></slot>
<template v-else>
<gl-sorting
ref="packageListSorting"
class="my-3"
:text="sortText"
:is-ascending="isSortAscending"
@sortDirectionChange="onDirectionChange"
>
<gl-sorting-item
v-for="item in sortableFields"
ref="packageListSortItem"
:key="item.key"
@click="onSortItemClick(item.key)"
>
{{ item.label }}
</gl-sorting-item>
</gl-sorting>
<gl-table
ref="packageListTable"
:items="list"
:fields="headerFields"
:no-local-sorting="true"
>
<template #name="{value}">
<div ref="col-name" class="flex-truncate-parent">
<a href="/asd/lol" class="flex-truncate-child" data-qa-selector="package_link">
{{ value }}
</a>
</div>
</template>
<template #version="{value}">
{{ value }}
</template>
<template #package_type="{value}">
{{ value }}
</template>
<template #created_at="{value}">
<time-ago-tooltip :time="value" />
</template>
<template #actions="{item}">
<gl-button
ref="action-delete"
variant="danger"
:title="s__('PackageRegistry|Remove package')"
:aria-label="s__('PackageRegistry|Remove package')"
@click="setItemToBeDeleted(item)"
>
<icon name="remove" />
</gl-button>
</template>
</gl-table>
<gl-pagination
ref="packageListPagination"
v-model="currentPage"
:per-page="perPage"
:total-items="totalItems"
align="center"
class="w-100"
/>
<gl-modal
ref="packageListDeleteModal"
:modal-id="modalId"
ok-variant="danger"
@ok="deleteItemConfirmation"
@cancel="deleteItemCanceled"
>
<template v-slot:modal-title>{{ modalAction }}</template>
<template v-slot:modal-ok>{{ modalAction }}</template>
<p v-html="deletePackageDescription"></p>
</gl-modal>
</template>
</div>
</template>
<script>
import { GlEmptyState } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import PackageList from './packages_list.vue';
export default {
components: {
GlEmptyState,
PackageList,
},
props: {
projectId: {
type: String,
required: true,
},
canDestroyPackage: {
type: Boolean,
required: false,
default: false,
},
emptyListIllustration: {
type: String,
required: true,
},
emptyListHelpUrl: {
type: String,
required: true,
},
},
computed: {
emptyListText() {
return sprintf(
s__(
'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.',
),
{
noPackagesLinkStart: `<a href="${this.emptyListHelpUrl}" target="_blank">`,
noPackagesLinkEnd: '</a>',
},
false,
);
},
},
};
</script>
<template>
<package-list :can-destroy-package="canDestroyPackage">
<template #empty-state>
<gl-empty-state
:title="s__('PackageRegistry|There are no packages yet')"
:svg-path="emptyListIllustration"
>
<template #description>
<p v-html="emptyListText"></p>
</template>
</gl-empty-state>
</template>
</package-list>
</template>
import Vue from 'vue';
import PackagesListApp from './components/packages_list_app.vue';
import Translate from '~/vue_shared/translate';
Vue.use(Translate);
export default () =>
new Vue({
el: '#js-vue-packages-list',
components: {
PackagesListApp,
},
data() {
const { dataset } = document.querySelector(this.$options.el);
return {
packageListAttrs: {
projectId: dataset.projectId,
emptyListIllustration: dataset.emptyListIllustration,
emptyListHelpUrl: dataset.emptyListHelpUrl,
canDestroyPackage: dataset.canDestroyPackage,
},
};
},
render(createElement) {
return createElement('packages-list-app', {
props: {
...this.packageListAttrs,
},
});
},
});
import initPackageList from 'ee/packages/list/packages_list_app_bundle';
document.addEventListener('DOMContentLoaded', initPackageList);
......@@ -4,7 +4,10 @@
- if vue_package_list_enabled_for?(@project)
.row
.col-12
#js-vue-packages-list{ data: { project_id: @project.id, 'can_destroy_package' => can_destroy_package } }
#js-vue-packages-list{ data: { project_id: @project.id,
can_destroy_package: can_destroy_package,
empty_list_help_url: help_page_path('administration/packages/index'),
empty_list_illustration: image_path('illustrations/no-packages.svg') } }
- else
= render "legacy_package_list", can_destroy_package: can_destroy_package
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`packages_list_app renders 1`] = `
<div>
<div
class="row empty-state"
>
<div
class="col-12"
>
<div
class="svg-250 svg-content"
>
<img
alt="There are no packages yet"
class=""
src="helpSvg"
/>
</div>
</div>
<div
class="col-12"
>
<div
class="text-content"
>
<h4
class="center"
style=""
>
There are no packages yet
</h4>
<p
class="center"
style=""
>
<p>
Learn how to
<a
href="helpUrl"
target="_blank"
>
publish and share your packages
</a>
with GitLab.
</p>
</p>
<div
class="text-center"
>
<!---->
<!---->
</div>
</div>
</div>
</div>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`packages_list renders 1`] = `
<div
class="d-flex flex-column align-items-end"
>
<glsorting-stub
class="my-3"
isascending="true"
sortdirectiontooltip="Sort direction"
text="Name"
>
<div>
Name
</div>
<div>
Version
</div>
<div>
Type
</div>
<div>
Created
</div>
</glsorting-stub>
<table
aria-busy="false"
aria-colcount="5"
class="table b-table gl-table"
id="__BVID__9"
>
<!---->
<!---->
<thead
class=""
role="rowgroup"
>
<!---->
<tr
role="row"
>
<th
aria-colindex="1"
class=""
role="columnheader"
scope="col"
>
Name
</th>
<th
aria-colindex="2"
class=""
role="columnheader"
scope="col"
>
Version
</th>
<th
aria-colindex="3"
class=""
role="columnheader"
scope="col"
>
Type
</th>
<th
aria-colindex="4"
class=""
role="columnheader"
scope="col"
>
Created
</th>
<th
aria-colindex="5"
aria-label="Actions"
class=""
role="columnheader"
scope="col"
>
</th>
</tr>
</thead>
<!---->
<tbody
class=""
role="rowgroup"
>
<!---->
<tr
class=""
role="row"
>
<td
aria-colindex="1"
class="w-25"
role="cell"
>
<div
class="flex-truncate-parent"
>
<a
class="flex-truncate-child"
data-qa-selector="package_link"
href="/asd/lol"
>
Test package
</a>
</div>
</td>
<td
aria-colindex="2"
class=""
role="cell"
>
1.0.0
</td>
<td
aria-colindex="3"
class=""
role="cell"
>
maven
</td>
<td
aria-colindex="4"
class=""
role="cell"
>
<timeagotooltip-stub
cssclass=""
time=""
tooltipplacement="top"
/>
</td>
<td
aria-colindex="5"
class="text-right"
role="cell"
>
<glbutton-stub
aria-label="Remove package"
title="Remove package"
variant="danger"
>
<icon-stub
name="remove"
size="16"
/>
</glbutton-stub>
</td>
</tr>
<tr
class=""
role="row"
>
<td
aria-colindex="1"
class="w-25"
role="cell"
>
<div
class="flex-truncate-parent"
>
<a
class="flex-truncate-child"
data-qa-selector="package_link"
href="/asd/lol"
>
@Test/package
</a>
</div>
</td>
<td
aria-colindex="2"
class=""
role="cell"
>
</td>
<td
aria-colindex="3"
class=""
role="cell"
>
npm
</td>
<td
aria-colindex="4"
class=""
role="cell"
>
<timeagotooltip-stub
cssclass=""
time=""
tooltipplacement="top"
/>
</td>
<td
aria-colindex="5"
class="text-right"
role="cell"
>
<glbutton-stub
aria-label="Remove package"
title="Remove package"
variant="danger"
>
<icon-stub
name="remove"
size="16"
/>
</glbutton-stub>
</td>
</tr>
<!---->
<!---->
</tbody>
</table>
<glpagination-stub
align="center"
class="w-100"
ellipsistext="…"
labelfirstpage="Go to first page"
labellastpage="Go to last page"
labelnextpage="Go to next page"
labelpage="function _default(page) {
return \\"Go to page \\".concat(page);
}"
labelprevpage="Go to previous page"
limits="[object Object]"
nexttext="Next ›"
perpage="20"
prevtext="‹ Prev"
totalitems="100"
value="1"
/>
<glmodal-stub
modalclass=""
modalid="confirm-delete-pacakge"
ok-variant="danger"
titletag="h4"
>
<p />
</glmodal-stub>
</div>
`;
import { shallowMount } from '@vue/test-utils';
import { GlEmptyState } from '@gitlab/ui';
import PackageListApp from 'ee/packages/list/components/packages_list_app.vue';
describe('packages_list_app', () => {
let wrapper;
const emptyListHelpUrl = 'helpUrl';
const findGlEmptyState = (w = wrapper) => w.find({ name: 'gl-empty-state-stub' });
beforeEach(() => {
wrapper = shallowMount(PackageListApp, {
propsData: {
projectId: '1',
emptyListIllustration: 'helpSvg',
emptyListHelpUrl,
},
stubs: {
'package-list': '<div><slot name="empty-state"></slot></div>',
GlEmptyState: { ...GlEmptyState, name: 'gl-empty-state-stub' },
},
});
});
afterEach(() => {
wrapper.destroy();
});
it('renders', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('generate the correct empty list link', () => {
const emptyState = findGlEmptyState();
const link = emptyState.find('a');
expect(link.html()).toMatchInlineSnapshot(
`"<a href=\\"${emptyListHelpUrl}\\" target=\\"_blank\\">publish and share your packages</a>"`,
);
});
});
import Vue from 'vue';
import { shallowMount } from '@vue/test-utils';
import { GlTable } from '@gitlab/ui';
import PackagesList from 'ee/packages/list/components/packages_list.vue';
import { packageList } from '../../mock_data';
describe('packages_list', () => {
let wrapper;
const findFirstActionColumn = (w = wrapper) => w.find({ ref: 'action-delete' });
const findPackageListTable = (w = wrapper) => w.find({ ref: 'packageListTable' });
const findPackageListSorting = (w = wrapper) => w.find({ ref: 'packageListSorting' });
const findPackageListPagination = (w = wrapper) => w.find({ ref: 'packageListPagination' });
const findPackageListDeleteModal = (w = wrapper) => w.find({ ref: 'packageListDeleteModal' });
const findSortingItems = (w = wrapper) => w.findAll({ name: 'sorting-item-stub' });
const defaultShallowMountOptions = {
propsData: {
canDestroyPackage: true,
},
stubs: {
GlTable,
GlSortingItem: { name: 'sorting-item-stub', template: '<div><slot></slot></div>' },
},
computed: {
list: () => [...packageList],
},
};
beforeEach(() => {
// This is needed due to console.error called by vue to emit a warning that stop the tests
// see https://github.com/vuejs/vue-test-utils/issues/532
Vue.config.silent = true;
wrapper = shallowMount(PackagesList, defaultShallowMountOptions);
});
afterEach(() => {
Vue.config.silent = false;
wrapper.destroy();
});
it('renders', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('contains a sorting component', () => {
const sorting = findPackageListSorting();
expect(sorting.exists()).toBe(true);
});
it('contains a table component', () => {
const sorting = findPackageListTable();
expect(sorting.exists()).toBe(true);
});
it('contains a pagination component', () => {
const sorting = findPackageListPagination();
expect(sorting.exists()).toBe(true);
});
it('contains a modal component', () => {
const sorting = findPackageListDeleteModal();
expect(sorting.exists()).toBe(true);
});
describe('when user can not destroy the package', () => {
it('does not show the action column', () => {
wrapper.setProps({ canDestroyPackage: false });
const action = findFirstActionColumn();
expect(action.exists()).toBe(false);
});
});
describe('when the user can destroy the package', () => {
it('show the action column', () => {
const action = findFirstActionColumn();
expect(action.exists()).toBe(true);
});
it('shows the correct deletePackageDescription', () => {
expect(wrapper.vm.deletePackageDescription).toEqual('');
wrapper.setData({ itemToBeDeleted: { name: 'foo' } });
expect(wrapper.vm.deletePackageDescription).toEqual(
'You are about to delete <b>foo</b>, this operation is irreversible, are you sure?',
);
});
it('delete button set itemToBeDeleted and open the modal', () => {
wrapper.vm.$refs.packageListDeleteModal.show = jest.fn();
const [{ name, id }] = packageList.slice(-1);
const action = findFirstActionColumn();
action.vm.$emit('click');
return Vue.nextTick().then(() => {
expect(wrapper.vm.itemToBeDeleted).toEqual({ id, name });
expect(wrapper.vm.$refs.packageListDeleteModal.show).toHaveBeenCalled();
});
});
it('deleteItemConfirmation resets itemToBeDeleted', () => {
wrapper.setData({ itemToBeDeleted: 1 });
wrapper.vm.deleteItemConfirmation();
expect(wrapper.vm.itemToBeDeleted).toEqual(null);
});
it('deleteItemCanceled resets itemToBeDeleted', () => {
wrapper.setData({ itemToBeDeleted: 1 });
wrapper.vm.deleteItemCanceled();
expect(wrapper.vm.itemToBeDeleted).toEqual(null);
});
});
describe('when the list is empty', () => {
const findEmptySlot = (w = wrapper) => w.find({ name: 'empty-slot-stub' });
beforeEach(() => {
wrapper = shallowMount(PackagesList, {
...defaultShallowMountOptions,
computed: { list: () => [] },
slots: {
'empty-state': { name: 'empty-slot-stub', template: '<div>bar</div>' },
},
});
});
it('show the empty slot', () => {
const table = findPackageListTable();
const emptySlot = findEmptySlot();
expect(table.exists()).toBe(false);
expect(emptySlot.exists()).toBe(true);
});
});
describe('sorting component', () => {
it('has all the sortable items', () => {
const sortingItems = findSortingItems();
expect(sortingItems.length).toEqual(wrapper.vm.sortableFields.length);
});
});
});
......@@ -49,3 +49,5 @@ export const npmFiles = [
download_path: '/-/package_files/2/download',
},
];
export const packageList = [mavenPackage, npmPackage];
......@@ -11842,15 +11842,30 @@ msgstr ""
msgid "Package was removed"
msgstr ""
msgid "PackageRegistry|Delete Package"
msgstr ""
msgid "PackageRegistry|Delete Package Version"
msgstr ""
msgid "PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab."
msgstr ""
msgid "PackageRegistry|Remove package"
msgstr ""
msgid "PackageRegistry|There are no packages yet"
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 <b>%{packageName}</b>, this operation is irreversible, are you sure?"
msgstr ""
msgid "PackageRegistry|You are about to delete version %{boldStart}%{version}%{boldEnd} of %{boldStart}%{name}%{boldEnd}. Are you sure?"
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