Commit 2ccf2f4e authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera Committed by Kushal Pandya

Add new package list basic app

Add list component based on gltable

Add index file to import vue app in rails page

Adjust base app, naming and props

List mock data, adjust slot syntax

Add styling, sorting component to list

Basic unit tests for package list component

More tests for list component

Update haml with required attrs for vue

- help url
- illustration path
- remove hardcoded list data
parent 1686da10
<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